diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index e6df1c2204..439f8e143a 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -278,146 +278,187 @@ export class RealmServer { } private serveIndex = async (ctxt: Koa.Context, next: Koa.Next) => { - if (ctxt.header.accept?.includes('text/html')) { - // If this is a /connect iframe request, is the origin a valid published realm? + let acceptHeader = ctxt.header.accept ?? ''; + let lowerAcceptHeader = acceptHeader.toLowerCase(); + let includesVndMimeType = lowerAcceptHeader.includes('application/vnd.'); + let includesHtmlMimeType = lowerAcceptHeader.includes('text/html'); - let connectMatch = ctxt.request.path.match(/\/connect\/(.+)$/); + let requestURL = new URL( + `${ctxt.protocol}://${ctxt.host}${ctxt.originalUrl}`, + ); - if (connectMatch) { - try { - let originParameter = new URL(decodeURIComponent(connectMatch[1])) - .href; + if (includesHtmlMimeType) { + if (includesVndMimeType) { + let isHostModeRequest = await this.isHostModeRequest(requestURL); - let publishedRealms = await query(this.dbAdapter, [ - `SELECT published_realm_url FROM published_realms WHERE published_realm_url LIKE `, - param(`${originParameter}%`), - ]); + if (isHostModeRequest) { + return next(); + } + } + } else { + if (includesVndMimeType) { + return next(); + } - if (publishedRealms.length === 0) { - ctxt.status = 404; - ctxt.body = `Not Found: No published realm found for origin ${originParameter}`; + let isHostModeRequest = await this.isHostModeRequest(requestURL); - this.log.debug( - `Ignoring /connect request for origin ${originParameter}: no matching published realm`, - ); + if (!isHostModeRequest) { + return next(); + } + } - return; - } + // If this is a /connect iframe request, is the origin a valid published realm? - ctxt.set( - 'Content-Security-Policy', - `frame-ancestors ${originParameter}`, - ); - } catch (error) { - ctxt.status = 400; - ctxt.body = 'Bad Request'; + let connectMatch = ctxt.request.path.match(/\/connect\/(.+)$/); + + if (connectMatch) { + try { + let originParameter = new URL(decodeURIComponent(connectMatch[1])).href; + + let publishedRealms = await query(this.dbAdapter, [ + `SELECT published_realm_url FROM published_realms WHERE published_realm_url LIKE `, + param(`${originParameter}%`), + ]); - this.log.info(`Error processing /connect request: ${error}`); + if (publishedRealms.length === 0) { + ctxt.status = 404; + ctxt.body = `Not Found: No published realm found for origin ${originParameter}`; + + this.log.debug( + `Ignoring /connect request for origin ${originParameter}: no matching published realm`, + ); return; } - } - - ctxt.type = 'html'; - let requestURL = new URL( - `${ctxt.protocol}://${ctxt.host}${ctxt.originalUrl}`, - ); - let cardURL = requestURL; - let isIndexRequest = requestURL.pathname.endsWith('/'); - if (isIndexRequest) { - cardURL = new URL('index', requestURL); - } + ctxt.set( + 'Content-Security-Policy', + `frame-ancestors ${originParameter}`, + ); + } catch (error) { + ctxt.status = 400; + ctxt.body = 'Bad Request'; - let indexHTML = await this.retrieveIndexHTML(); - let hasPublicPermissions = await this.hasPublicPermissions(cardURL); + this.log.info(`Error processing /connect request: ${error}`); - if (!hasPublicPermissions) { - ctxt.body = indexHTML; return; } + } - this.headLog.debug(`Fetching head HTML for ${cardURL.href}`); - this.isolatedLog.debug(`Fetching isolated HTML for ${cardURL.href}`); - this.scopedCSSLog.debug(`Fetching scoped CSS for ${cardURL.href}`); + ctxt.type = 'html'; - let [headHTML, isolatedHTML, scopedCSS] = await Promise.all([ - this.retrieveHeadHTML(cardURL), - this.retrieveIsolatedHTML(cardURL), - retrieveScopedCSS({ - cardURL, - dbAdapter: this.dbAdapter, - indexURLCandidates: (url) => this.indexURLCandidates(url), - indexCandidateExpressions: (candidates) => - this.indexCandidateExpressions(candidates), - log: this.scopedCSSLog, - }), - ]); + let cardURL = requestURL; + let isIndexRequest = requestURL.pathname.endsWith('/'); + if (isIndexRequest) { + cardURL = new URL('index', requestURL); + } - if (headHTML != null) { - this.headLog.debug( - `Injecting head HTML for ${cardURL.href} (length ${headHTML.length})\n${this.truncateLogLines( - headHTML, - )}`, - ); - } else { - this.headLog.debug( - `No head HTML found for ${cardURL.href}, serving base index.html`, - ); - } + let indexHTML = await this.retrieveIndexHTML(); + let hasPublicPermissions = await this.hasPublicPermissions(cardURL); - if (scopedCSS != null) { - this.scopedCSSLog.debug( - `Using scoped CSS for ${cardURL.href} (length ${scopedCSS.length})`, - ); - } else { - this.scopedCSSLog.debug( - `No scoped CSS returned from database for ${cardURL.href}`, - ); - } + if (!hasPublicPermissions) { + ctxt.body = indexHTML; + return; + } - let responseHTML = indexHTML; - let headFragments: string[] = []; + this.headLog.debug(`Fetching head HTML for ${cardURL.href}`); + this.isolatedLog.debug(`Fetching isolated HTML for ${cardURL.href}`); + this.scopedCSSLog.debug(`Fetching scoped CSS for ${cardURL.href}`); - if (headHTML != null) { - headFragments.push(headHTML); - } + let [headHTML, isolatedHTML, scopedCSS] = await Promise.all([ + this.retrieveHeadHTML(cardURL), + this.retrieveIsolatedHTML(cardURL), + retrieveScopedCSS({ + cardURL, + dbAdapter: this.dbAdapter, + indexURLCandidates: (url) => this.indexURLCandidates(url), + indexCandidateExpressions: (candidates) => + this.indexCandidateExpressions(candidates), + log: this.scopedCSSLog, + }), + ]); - if (scopedCSS != null) { - this.scopedCSSLog.debug(`Injecting scoped CSS for ${cardURL.href}`); - headFragments.push( - ``, - ); - } + if (headHTML != null) { + this.headLog.debug( + `Injecting head HTML for ${cardURL.href} (length ${headHTML.length})\n${this.truncateLogLines( + headHTML, + )}`, + ); + } else { + this.headLog.debug( + `No head HTML found for ${cardURL.href}, serving base index.html`, + ); + } - if (headFragments.length > 0) { - responseHTML = this.injectHeadHTML( - responseHTML, - headFragments.join('\n'), - ); - } + if (scopedCSS != null) { + this.scopedCSSLog.debug( + `Using scoped CSS for ${cardURL.href} (length ${scopedCSS.length})`, + ); + } else { + this.scopedCSSLog.debug( + `No scoped CSS returned from database for ${cardURL.href}`, + ); + } - if (isolatedHTML != null) { - this.isolatedLog.debug( - `Injecting isolated HTML for ${cardURL.href} (length ${isolatedHTML.length})\n${this.truncateLogLines( - isolatedHTML, - )}`, - ); - responseHTML = this.injectIsolatedHTML(responseHTML, isolatedHTML); - } + let responseHTML = indexHTML; + let headFragments: string[] = []; - ctxt.body = responseHTML; - return; + if (headHTML != null) { + headFragments.push(headHTML); } - return next(); + + if (scopedCSS != null) { + this.scopedCSSLog.debug(`Injecting scoped CSS for ${cardURL.href}`); + headFragments.push( + ``, + ); + } + + if (headFragments.length > 0) { + responseHTML = this.injectHeadHTML( + responseHTML, + headFragments.join('\n'), + ); + } + + if (isolatedHTML != null) { + this.isolatedLog.debug( + `Injecting isolated HTML for ${cardURL.href} (length ${isolatedHTML.length})\n${this.truncateLogLines( + isolatedHTML, + )}`, + ); + responseHTML = this.injectIsolatedHTML(responseHTML, isolatedHTML); + } + + ctxt.body = responseHTML; + return; + return; }; - private async hasPublicPermissions(cardURL: URL): Promise { - let realm = this.realms.find((candidate) => { + private findRealmForRequestURL(requestURL: URL): Realm | undefined { + return this.realms.find((candidate) => { let realmURL = new URL(candidate.url); - realmURL.protocol = cardURL.protocol; - return new RealmPaths(realmURL).inRealm(cardURL); + realmURL.protocol = requestURL.protocol; + return new RealmPaths(realmURL).inRealm(requestURL); }); + } + + private async isHostModeRequest(requestURL: URL): Promise { + let realm = this.findRealmForRequestURL(requestURL); + if (!realm) { + return false; + } + + let rows = await query(this.dbAdapter, [ + `SELECT published_realm_url FROM published_realms WHERE published_realm_url =`, + param(realm.url), + ]); + + return rows.length > 0; + } + + private async hasPublicPermissions(cardURL: URL): Promise { + let realm = this.findRealmForRequestURL(cardURL); if (!realm) { return false; diff --git a/packages/realm-server/tests/server-endpoints/index-responses-test.ts b/packages/realm-server/tests/server-endpoints/index-responses-test.ts index eb951353ae..0d117f1ef2 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -1,8 +1,9 @@ import { module, test } from 'qunit'; import { join, basename } from 'path'; +import type { Test, SuperTest } from 'supertest'; import { systemInitiatedPriority } from '@cardstack/runtime-common'; import { setupServerEndpointsTest, testRealm2URL } from './helpers'; -import { waitUntil } from '../helpers'; +import { setupPermissionedRealmAtURL, waitUntil } from '../helpers'; import { ensureDirSync, writeFileSync, writeJSONSync } from 'fs-extra'; import '@cardstack/runtime-common/helpers/code-equality-assertion'; @@ -352,4 +353,49 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); }, ); + + module('Published realm index responses', function (hooks) { + let realmURL = new URL('http://127.0.0.1:4444/'); + let request: SuperTest; + + function onRealmSetup(args: { + request: SuperTest; + }) { + request = args.request; + } + + setupPermissionedRealmAtURL(hooks, realmURL, { + permissions: { + '*': ['read'], + }, + published: true, + onRealmSetup, + }); + + test('serves index HTML by default for published realm', async function (assert) { + let response = await request.get('/').set('Accept', 'application/json'); + + 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('data-test-home-card'), + 'index HTML is served', + ); + }); + + test('skips index HTML when vendor mime type is requested', async function (assert) { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + + assert.strictEqual(response.status, 200, 'serves JSON response'); + assert.ok( + response.headers['content-type']?.includes('application/vnd.card+json'), + 'content type is vendor JSON', + ); + }); + }); });