diff --git a/server/app.test.js b/server/app.test.js
new file mode 100644
index 000000000..8c4b20d67
--- /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 8737cb77c..ee9e8958f 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 d3fb9d153..5a3f6e542 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 f4318d20a..576441dfc 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,