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
8 changes: 6 additions & 2 deletions packages/base/file-formats/pdf-viewer.gts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ import { FileObject } from './file-resources';
import type { FilePreviewSignature } from './file-preview-stage';

export class PdfViewer extends GlimmerComponent<FilePreviewSignature> {
// The served document URL. The auth service worker injects the realm token on
// the native `<object>` request, the same path `<img>`/`<audio>` use.
// The served document URL, loaded by the native `<object>` as a plain
// browser fetch. `<object>`/`<embed>` loads bypass service workers (per
// the ServiceWorker spec), so no Authorization header can be attached:
// the document renders only when the realm is publicly readable. The
// realm's content negotiation keys off the request's Sec-Fetch-Dest to
// serve the file's bytes here rather than the host app shell.
get resourceUrl(): string {
return this.args.model?.resourceUrl ?? this.args.model?.url ?? '';
}
Expand Down
29 changes: 29 additions & 0 deletions packages/realm-server/handlers/serve-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ const headLog = logger('realm-server:head');
const isolatedLog = logger('realm-server:isolated');
const scopedCSSLog = logger('realm-server:scoped-css');

// An <object>/<embed> load advertises text/html in its Accept header (the
// browser issues it as a frame-style navigation), but it is always embedding
// a document, never the app — answering with the shell boots the host app
// recursively inside the preview. Sec-Fetch-Dest is the only place these
// loads are distinguishable from an address-bar navigation (which carries
// `document` and must keep opening the app); browsers stamp it on requests
// to trustworthy origins (HTTPS and localhost). The distinction has to be
// drawn here on the server: service workers are spec-required to pass
// <object>/<embed> loads straight to the network without a fetch event, so
// no client-side layer ever sees these requests.
function isDocumentEmbedRequest(ctxt: Koa.Context): boolean {
let destination = ctxt.header['sec-fetch-dest'];
return destination === 'embed' || destination === 'object';
}

export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
let {
serverURL,
Expand Down Expand Up @@ -212,6 +227,11 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
}

let serveIndex = async (ctxt: Koa.Context, next: Koa.Next) => {
if (isDocumentEmbedRequest(ctxt)) {
// Fall through to the realm, which serves the file's own bytes and
// lets its content type decide what the embed renders.
return next();
}
let acceptHeader = ctxt.header.accept ?? '';
let lowerAcceptHeader = acceptHeader.toLowerCase();
let includesVndMimeType = lowerAcceptHeader.includes('application/vnd.');
Expand Down Expand Up @@ -377,6 +397,9 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
ctxt.set('ETag', etag);
ctxt.set('Cache-Control', 'public, max-age=0, must-revalidate');
ctxt.vary('Accept');
// The shell is withheld from document embeds (see
// isDocumentEmbedRequest), so a cached copy must not satisfy them.
ctxt.vary('Sec-Fetch-Dest');
return;
}
}
Expand Down Expand Up @@ -607,6 +630,9 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
ctxt.set('ETag', etag);
ctxt.set('Cache-Control', 'public, max-age=0, must-revalidate');
ctxt.vary('Accept');
// The shell is withheld from document embeds (see
// isDocumentEmbedRequest), so a cached copy must not satisfy them.
ctxt.vary('Sec-Fetch-Dest');
}

ctxt.body = responseHTML;
Expand All @@ -616,6 +642,9 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
let serveHostApp = async (ctxt: Koa.Context, next: Koa.Next) => {
let acceptHeader = (ctxt.header.accept ?? '').toLowerCase();
let isHead = ctxt.method === 'HEAD';
if (isDocumentEmbedRequest(ctxt)) {
return next();
}
if (!isHead && !acceptHeader.includes('text/html')) {
return next();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () {
</template>
};
}`,
// A binary document for the embed-negotiation tests — the realm
// serves it verbatim with its extension's content type.
'report.pdf': '%PDF-1.4 fake-pdf-bytes',
'person.gts': `import {
contains,
field,
Expand Down Expand Up @@ -1164,6 +1167,85 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () {
'scoped CSS content is preserved from last_known_good_deps after card enters error state',
);
});

// An <object>/<embed> load advertises text/html in its Accept header
// (the browser issues it as a frame-style navigation), but it is
// embedding a document — an app-shell answer would boot the host app
// recursively inside the preview. The server tells these loads apart
// from address-bar navigations by Sec-Fetch-Dest (see
// isDocumentEmbedRequest in handlers/serve-index.ts). Service workers
// are spec-required to bypass <object>/<embed> loads, so this
// negotiation is only testable — and only fixable — at the wire.
module('document embed negotiation', function () {
// The Accept header a browser attaches both to <object>/<embed>
// loads and to address-bar navigations — Sec-Fetch-Dest is the only
// discriminator between them.
const FRAME_STYLE_ACCEPT =
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7';

test('serves document bytes, not the app shell, when Sec-Fetch-Dest is embed', async function (assert) {
let response = await request
.get('/test/report.pdf')
.set('Accept', FRAME_STYLE_ACCEPT)
.set('Sec-Fetch-Dest', 'embed');

assert.strictEqual(response.status, 200, 'serves the file');
assert.ok(
response.headers['content-type']?.includes('application/pdf'),
`content type comes from the file, not the shell (got ${response.headers['content-type']})`,
);
assert.notOk(
(response.text ?? '').includes('<title>'),
'the app shell is not served to a document embed',
);
});

test('serves document bytes when Sec-Fetch-Dest is object', async function (assert) {
let response = await request
.get('/test/report.pdf')
.set('Accept', FRAME_STYLE_ACCEPT)
.set('Sec-Fetch-Dest', 'object');

assert.strictEqual(response.status, 200, 'serves the file');
assert.ok(
response.headers['content-type']?.includes('application/pdf'),
`content type comes from the file, not the shell (got ${response.headers['content-type']})`,
);
});

test('an address-bar navigation to the same file URL still opens the app', async function (assert) {
let response = await request
.get('/test/report.pdf')
.set('Accept', FRAME_STYLE_ACCEPT)
.set('Sec-Fetch-Dest', 'document');

assert.strictEqual(response.status, 200, 'serves HTML response');
assert.ok(
response.headers['content-type']?.includes('text/html'),
'content type is text/html',
);
assert.ok(
response.text.includes('<title>'),
'the app shell is served',
);
});

test('an HTML-accepting request with no Sec-Fetch-Dest still opens the app', async function (assert) {
let response = await request
.get('/test/report.pdf')
.set('Accept', FRAME_STYLE_ACCEPT);

assert.strictEqual(response.status, 200, 'serves HTML response');
assert.ok(
response.headers['content-type']?.includes('text/html'),
'content type is text/html',
);
assert.ok(
response.text.includes('<title>'),
'the app shell is served',
);
});
});
},
);

Expand Down Expand Up @@ -1248,6 +1330,10 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () {
response.headers['vary']?.includes('Accept'),
'Vary header includes Accept',
);
assert.ok(
response.headers['vary']?.includes('Sec-Fetch-Dest'),
'Vary header includes Sec-Fetch-Dest (the shell is withheld from document embeds, so a cached copy must not satisfy them)',
);
});

test('HEAD request includes ETag and Cache-Control headers', async function (assert) {
Expand Down
Loading