feat: Return publicly shared document title in SSR HTML (#2191)

* feat: Return publicly shared document title in SSR HTML
closes #2146

* tests
This commit is contained in:
Tom Moor 2021-06-09 17:41:39 -07:00 committed by GitHub
parent 4cd61db1ea
commit a99f6bed42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 93 additions and 6 deletions

52
server/app.test.js Normal file
View File

@ -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("<title>Outline</title>");
});
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("<title>Outline</title>");
});
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(`<title>${document.title}</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(`<title>${document.title}</title>`);
});
});

View File

@ -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);

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Outline</title>
<title>//inject-title//</title>
<meta name="theme-color" content="#FFF" />
<meta name="slack-app-id" content="//inject-slack-app-id//" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@ -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,