Skip to content
Draft
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
18 changes: 14 additions & 4 deletions packages/host/app/lib/stack-item.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
isFileDefInstance,
isFileMetaResource,
isSingleCardDocument,
isSingleFileMetaDocument,
isLinkableCollectionDocument,
resolveFileDefCodeRef,
X_BOXEL_CONSUMING_REALM_HEADER,
Expand Down Expand Up @@ -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<FileDef>({
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)}`,
Expand Down
39 changes: 35 additions & 4 deletions packages/realm-server/tests/card-endpoints-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime-common/file-def-code-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
22 changes: 20 additions & 2 deletions packages/runtime-common/realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
Loading