diff --git a/packages/host/app/lib/stack-item.ts b/packages/host/app/lib/stack-item.ts index 88efabd936e..aaae7c49011 100644 --- a/packages/host/app/lib/stack-item.ts +++ b/packages/host/app/lib/stack-item.ts @@ -1,6 +1,9 @@ import { tracked } from '@glimmer/tracking'; -import { isFileDefInstance } from '@cardstack/runtime-common'; +import { + isFileDefInstance, + isFileDefExtension, +} from '@cardstack/runtime-common'; import type { Deferred } from '@cardstack/runtime-common'; import type { Store, StoreReadType } from '@cardstack/runtime-common'; @@ -22,8 +25,15 @@ interface Args { export type StackItemType = 'card' | 'file'; -function inferStackItemType(type?: StackItemType): StackItemType { - return type === 'file' ? 'file' : 'card'; +function inferStackItemType( + type: StackItemType | undefined, + id?: string, +): StackItemType { + if (type === 'file' || type === 'card') { + return type; + } + // URL extension known to the FileDef registry → file-meta load path. + return id && isFileDefExtension(id) ? 'file' : 'card'; } export function stackItemTypeToStoreReadType( @@ -100,7 +110,7 @@ export class StackItem { this.format = format; this.request = request; this.stackIndex = stackIndex; - this.type = inferStackItemType(type); + this.type = inferStackItemType(type, this.#id); this.useBaseTemplate = useBaseTemplate; this.relationshipContext = relationshipContext; this.lastInteractedAt = lastInteractedAt ?? ++nextInteractionSequence; diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 38b2ca893c1..93af6b3720a 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -31,6 +31,7 @@ import { isFileDefInstance, isFileMetaResource, isSingleCardDocument, + isSingleFileMetaDocument, isLinkableCollectionDocument, resolveFileDefCodeRef, X_BOXEL_CONSUMING_REALM_HEADER, @@ -1639,6 +1640,23 @@ export default class StoreService extends Service implements StoreInterface { json = await this.cardService.fetchJSON(url); } if (!isSingleCardDocument(json)) { + // The URL turned out to be a binary file (e.g. an uploaded + // image). The realm-server returns a file-meta JSON document + // in that case; reroute to the file-meta load path so the + // caller gets a FileDef instead of a hard failure. + if (isSingleFileMetaDocument(json)) { + // URL was a binary file; reroute to the file-meta bucket. + let fileMeta = await this.getFileMetaInstance({ + idOrDoc: url, + opts: { + noCache: opts?.noCache, + dependencyTrackingContext: opts?.dependencyTrackingContext, + }, + }); + // Resolve inflightGetCards so concurrent callers don't hang. + deferred?.fulfill(fileMeta as unknown as T | CardErrorJSONAPI); + return fileMeta as unknown as T; + } throw new Error( `bug: server returned a non card document for ${url}: ${JSON.stringify(json, null, 2)}`, diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index fd108292068..f70b465ef2c 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -4260,18 +4260,49 @@ module(basename(__filename), function () { onRealmSetup, }); - test('rejects HTTP requests to file URLs', async function (assert) { - let response; - response = await request + test('GET on a file URL with a card+json Accept returns a file-meta JSON document', async function (assert) { + let response = await request .get('/greeting.txt') .set('Accept', 'application/vnd.card+json'); + assert.strictEqual( + response.status, + 200, + 'GET serves a file-meta document instead of 415', + ); + assert.true( + (response.headers['content-type'] ?? '').startsWith( + 'application/vnd.card+json', + ), + 'response is JSON, not raw file bytes', + ); + let doc = JSON.parse(response.text); + assert.strictEqual( + doc?.data?.type, + 'file-meta', + 'data.type identifies the resource as file-meta', + ); + assert.strictEqual( + doc?.data?.attributes?.name, + 'greeting.txt', + 'attributes.name carries the file name', + ); + }); + + test('GET on a file URL with a card+markdown Accept returns 415', async function (assert) { + let response = await request + .get('/greeting.txt') + .set('Accept', 'application/vnd.card+markdown'); + assert.strictEqual( response.status, 415, - 'rejects GET for a file URL with 415 status', + 'markdown cannot represent a binary file', ); + }); + test('rejects write requests to file URLs', async function (assert) { + let response; response = await request .patch('/greeting.txt') .send({ diff --git a/packages/runtime-common/file-def-code-ref.ts b/packages/runtime-common/file-def-code-ref.ts index 757cdf77dad..e1f5147700b 100644 --- a/packages/runtime-common/file-def-code-ref.ts +++ b/packages/runtime-common/file-def-code-ref.ts @@ -55,3 +55,17 @@ export function resolveFileDefCodeRef(fileURL: URL): ResolvedCodeRef { ) as RealmResourceIdentifier, }; } + +// True when the given file/URL has an extension the realm recognizes as a +// FileDef subtype (image, markdown, gts/ts, etc.). Used to pick between +// card and file-meta load paths for an arbitrary URL. +export function isFileDefExtension(filenameOrPath: string): boolean { + let path = filenameOrPath.split(/[?#]/)[0]; + let segment = path.split('/').pop() ?? ''; + let dot = segment.lastIndexOf('.'); + if (dot === -1) { + return false; + } + let extension = segment.slice(dot).toLowerCase(); + return extension in FILEDEF_CODE_REF_BY_EXTENSION; +} diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 7c1cb53ed00..44982f5582e 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -4323,7 +4323,17 @@ export class Realm { }); if (instanceEntry === undefined) { if (await this.nonJsonFileExists(localPath)) { - return unsupportedMediaType(request, requestContext); + // A path that points to a non-JSON file (e.g. an uploaded + // binary) was asked for as card+json. Return a file-meta JSON + // document so the caller receives valid JSON it can + // discriminate via `data.type === 'file-meta'` — instead of + // raw binary bytes that crash a downstream `response.json()`. + let fileMeta = await this.fileMetaDocument( + requestContext, + localPath, + SupportedMimeType.CardJson, + ); + return fileMeta ?? notFound(request, requestContext); } else { return notFound(request, requestContext); } @@ -4367,7 +4377,12 @@ export class Realm { }); if (maybeError === undefined) { if (await this.nonJsonFileExists(localPath)) { - return unsupportedMediaType(request, requestContext); + let fileMeta = await this.fileMetaDocument( + requestContext, + localPath, + SupportedMimeType.CardJson, + ); + return fileMeta ?? notFound(request, requestContext); } else { return notFound(request, requestContext); } @@ -4469,6 +4484,9 @@ export class Realm { }); if (!entry) { if (await this.nonJsonFileExists(localPath)) { + // Markdown can't represent a binary file; keep the 415 here. + // The card+json sibling handler returns a file-meta JSON doc for + // the same scenario. return unsupportedMediaType(request, requestContext); } return notFound(request, requestContext);