From a99f6bed4291f866dd6dcdf830220df8789b8d60 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 9 Jun 2021 17:41:39 -0700 Subject: [PATCH] feat: Return publicly shared document title in SSR HTML (#2191) * feat: Return publicly shared document title in SSR HTML closes #2146 * tests --- server/app.test.js | 52 ++++++++++++++++++++++++++++++++++++++++ server/routes.js | 38 +++++++++++++++++++++++++---- server/static/index.html | 2 +- server/test/factories.js | 7 ++++++ 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 server/app.test.js diff --git a/server/app.test.js b/server/app.test.js new file mode 100644 index 00000000..8c4b20d6 --- /dev/null +++ b/server/app.test.js @@ -0,0 +1,52 @@ +// @flow +import TestServer from "fetch-test-server"; +import app from "./app"; +import { buildShare, buildDocument } from "./test/factories"; +import { flushdb } from "./test/support"; + +const server = new TestServer(app.callback()); + +beforeEach(() => flushdb()); +afterAll(() => server.close()); + +describe("/share/:id", () => { + it("should return standard title in html when loading share", async () => { + const share = await buildShare({ published: false }); + + const res = await server.get(`/share/${share.id}`); + const body = await res.text(); + + expect(res.status).toEqual(200); + expect(body).toContain("Outline"); + }); + + it("should return standard title in html when share does not exist", async () => { + const res = await server.get(`/share/junk`); + const body = await res.text(); + + expect(res.status).toEqual(200); + expect(body).toContain("Outline"); + }); + + it("should return document title in html when loading published share", async () => { + const document = await buildDocument(); + const share = await buildShare({ documentId: document.id }); + + const res = await server.get(`/share/${share.id}`); + const body = await res.text(); + + expect(res.status).toEqual(200); + expect(body).toContain(`${document.title}`); + }); + + it("should return document title in html when loading published share with nested doc route", async () => { + const document = await buildDocument(); + const share = await buildShare({ documentId: document.id }); + + const res = await server.get(`/share/${share.id}/doc/test-Cl6g1AgPYn`); + const body = await res.text(); + + expect(res.status).toEqual(200); + expect(body).toContain(`${document.title}`); + }); +}); diff --git a/server/routes.js b/server/routes.js index 8737cb77..ee9e8958 100644 --- a/server/routes.js +++ b/server/routes.js @@ -6,14 +6,17 @@ import Koa from "koa"; import Router from "koa-router"; import sendfile from "koa-sendfile"; import serve from "koa-static"; +import isUUID from "validator/lib/isUUID"; import { languages } from "../shared/i18n"; import env from "./env"; import apexRedirect from "./middlewares/apexRedirect"; +import Share from "./models/Share"; import { opensearchResponse } from "./utils/opensearch"; import prefetchTags from "./utils/prefetchTags"; import { robotsResponse } from "./utils/robots"; const isProduction = process.env.NODE_ENV === "production"; +const isTest = process.env.NODE_ENV === "test"; const koa = new Koa(); const router = new Router(); const readFile = util.promisify(fs.readFile); @@ -22,6 +25,9 @@ const readIndexFile = async (ctx) => { if (isProduction) { return readFile(path.join(__dirname, "../app/index.html")); } + if (isTest) { + return readFile(path.join(__dirname, "/static/index.html")); + } const middleware = ctx.devMiddleware; await new Promise((resolve) => middleware.waitUntilValid(resolve)); @@ -39,7 +45,7 @@ const readIndexFile = async (ctx) => { }); }; -const renderApp = async (ctx, next) => { +const renderApp = async (ctx, next, title = "Outline") => { if (ctx.request.path === "/realtime/") { return next(); } @@ -51,10 +57,34 @@ const renderApp = async (ctx, next) => { ctx.body = page .toString() .replace(/\/\/inject-env\/\//g, environment) + .replace(/\/\/inject-title\/\//g, title) .replace(/\/\/inject-prefetch\/\//g, prefetchTags) .replace(/\/\/inject-slack-app-id\/\//g, process.env.SLACK_APP_ID || ""); }; +const renderShare = async (ctx, next) => { + const { shareId } = ctx.params; + + // Find the share record if publicly published so that the document title + // can be be returned in the server-rendered HTML. This allows it to appear in + // unfurls with more reliablity + let share; + + if (isUUID(shareId)) { + share = await Share.findOne({ + where: { + id: shareId, + published: true, + }, + }); + } + + // Allow shares to be embedded in iframes on other websites + ctx.remove("X-Frame-Options"); + + return renderApp(ctx, next, share ? share.document.title : undefined); +}; + // serve static assets koa.use( serve(path.resolve(__dirname, "../../public"), { @@ -105,10 +135,8 @@ router.get("/opensearch.xml", (ctx) => { ctx.body = opensearchResponse(); }); -router.get("/share/*", (ctx, next) => { - ctx.remove("X-Frame-Options"); - return renderApp(ctx, next); -}); +router.get("/share/:shareId", renderShare); +router.get("/share/:shareId/*", renderShare); // catch all for application router.get("*", renderApp); diff --git a/server/static/index.html b/server/static/index.html index d3fb9d15..5a3f6e54 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -1,7 +1,7 @@ - Outline + //inject-title// diff --git a/server/test/factories.js b/server/test/factories.js index f4318d20..576441df 100644 --- a/server/test/factories.js +++ b/server/test/factories.js @@ -26,6 +26,13 @@ export async function buildShare(overrides: Object = {}) { const user = await buildUser({ teamId: overrides.teamId }); overrides.userId = user.id; } + if (!overrides.documentId) { + const document = await buildDocument({ + createdById: overrides.userId, + teamId: overrides.teamId, + }); + overrides.documentId = document.id; + } return Share.create({ published: true,