feat: Add CDN support (#1817)

* chore: CSP

* chore: Optionally use CDN for serving images
This commit is contained in:
Tom Moor 2021-01-16 11:12:10 -08:00 committed by GitHub
parent 1fd2ec31fd
commit 522df125aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 77 additions and 28 deletions

View File

@ -10,9 +10,14 @@ DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
REDIS_URL=redis://localhost:6479 REDIS_URL=redis://localhost:6479
# Must point to the publicly accessible URL for the installation
URL=http://localhost:3000 URL=http://localhost:3000
PORT=3000 PORT=3000
# Optional. If using a Cloudfront distribution or similar the origin server
# should be set to the same as URL.
CDN_URL=
# enforce (auto redirect to) https in production, (optional) default is true. # enforce (auto redirect to) https in production, (optional) default is true.
# set to false if your SSL is terminated at a loadbalancer, for example # set to false if your SSL is terminated at a loadbalancer, for example
FORCE_HTTPS=true FORCE_HTTPS=true
@ -66,4 +71,6 @@ SMTP_REPLY_EMAIL=
# Custom logo that displays on the authentication screen, scaled to height: 60px # Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png # TEAM_LOGO=https://example.com/images/logo.png
# See translate.getoutline.com for a list of available language codes and their
# percentage translated.
DEFAULT_LANGUAGE=en_US DEFAULT_LANGUAGE=en_US

View File

@ -8,9 +8,9 @@ import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary"; import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip"; import Tooltip from "components/Tooltip";
import embeds from "../embeds"; import embeds from "../embeds";
import isInternalUrl from "utils/isInternalUrl";
import { isMetaKey } from "utils/keyboard"; import { isMetaKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile"; import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor")); const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));

View File

@ -8,7 +8,7 @@ import { fadeAndSlideIn } from "shared/styles/animations";
import parseDocumentSlug from "shared/utils/parseDocumentSlug"; import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument"; import HoverPreviewDocument from "components/HoverPreviewDocument";
import isInternalUrl from "utils/isInternalUrl"; import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 300; const DELAY_OPEN = 300;
const DELAY_CLOSE = 300; const DELAY_CLOSE = 300;

12
app/components/Image.js Normal file
View File

@ -0,0 +1,12 @@
// @flow
import * as React from "react";
import { cdnPath } from "utils/urls";
type Props = {
alt: string,
src: string,
};
export default function Image({ src, alt, ...rest }: Props) {
return <img src={cdnPath(src)} alt={alt} {...rest} />;
}

View File

@ -1,16 +1,17 @@
// @flow // @flow
import { observer, inject } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import AuthStore from "stores/AuthStore"; import useStores from "hooks/useStores";
import { cdnPath } from "utils/urls";
type Props = { type Props = {|
title: string, title: string,
favicon?: string, favicon?: string,
auth: AuthStore, |};
};
const PageTitle = observer(({ auth, title, favicon }: Props) => { const PageTitle = ({ title, favicon }: Props) => {
const { auth } = useStores();
const { team } = auth; const { team } = auth;
return ( return (
@ -21,12 +22,12 @@ const PageTitle = observer(({ auth, title, favicon }: Props) => {
<link <link
rel="shortcut icon" rel="shortcut icon"
type="image/png" type="image/png"
href={favicon || "/favicon-32.png"} href={favicon || cdnPath("/favicon-32.png")}
sizes="32x32" sizes="32x32"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Helmet> </Helmet>
); );
}); };
export default inject("auth")(PageTitle); export default observer(PageTitle);

View File

@ -1,5 +1,6 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame"; import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$"); const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
@ -20,7 +21,7 @@ export default class GoogleDocs extends React.Component<Props> {
{...this.props} {...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")} src={this.props.attrs.href.replace("/edit", "/preview")}
icon={ icon={
<img <Image
src="/images/google-docs.png" src="/images/google-docs.png"
alt="Google Docs Icon" alt="Google Docs Icon"
width={16} width={16}

View File

@ -1,5 +1,6 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame"; import Frame from "./components/Frame";
const URL_REGEX = new RegExp( const URL_REGEX = new RegExp(
@ -21,7 +22,7 @@ export default class GoogleDrive extends React.Component<Props> {
<Frame <Frame
src={this.props.attrs.href.replace("/view", "/preview")} src={this.props.attrs.href.replace("/view", "/preview")}
icon={ icon={
<img <Image
src="/images/google-drive.png" src="/images/google-drive.png"
alt="Google Drive Icon" alt="Google Drive Icon"
width={16} width={16}

View File

@ -1,5 +1,6 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame"; import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$"); const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
@ -20,7 +21,7 @@ export default class GoogleSlides extends React.Component<Props> {
{...this.props} {...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")} src={this.props.attrs.href.replace("/edit", "/preview")}
icon={ icon={
<img <Image
src="/images/google-sheets.png" src="/images/google-sheets.png"
alt="Google Sheets Icon" alt="Google Sheets Icon"
width={16} width={16}

View File

@ -1,5 +1,6 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame"; import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$"); const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
@ -22,7 +23,7 @@ export default class GoogleSlides extends React.Component<Props> {
.replace("/edit", "/preview") .replace("/edit", "/preview")
.replace("/pub", "/embed")} .replace("/pub", "/embed")}
icon={ icon={
<img <Image
src="/images/google-slides.png" src="/images/google-slides.png"
alt="Google Slides Icon" alt="Google Slides Icon"
width={16} width={16}

View File

@ -1,6 +1,7 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import Image from "components/Image";
import Abstract from "./Abstract"; import Abstract from "./Abstract";
import Airtable from "./Airtable"; import Airtable from "./Airtable";
import ClickUp from "./ClickUp"; import ClickUp from "./ClickUp";
@ -38,7 +39,7 @@ function matcher(Component) {
}; };
} }
const Img = styled.img` const Img = styled(Image)`
margin: 4px; margin: 4px;
width: 18px; width: 18px;
height: 18px; height: 18px;

View File

@ -24,8 +24,8 @@ import Loading from "./Loading";
import SocketPresence from "./SocketPresence"; import SocketPresence from "./SocketPresence";
import { type LocationWithState, type Theme } from "types"; import { type LocationWithState, type Theme } from "types";
import { NotFoundError, OfflineError } from "utils/errors"; import { NotFoundError, OfflineError } from "utils/errors";
import isInternalUrl from "utils/isInternalUrl";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers"; import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
type Props = {| type Props = {|
match: Match, match: Match,

View File

@ -1,7 +1,8 @@
// @flow // @flow
import { parseDomain } from "../../shared/utils/domains"; import { parseDomain } from "../../shared/utils/domains";
import env from "env";
export default function isInternalUrl(href: string) { export function isInternalUrl(href: string) {
if (href[0] === "/") return true; if (href[0] === "/") return true;
const outline = parseDomain(window.location.href); const outline = parseDomain(window.location.href);
@ -19,3 +20,11 @@ export default function isInternalUrl(href: string) {
return false; return false;
} }
export function cdnPath(path: string): string {
return `${env.CDN_URL}${path}`;
}
export function imagePath(path: string): string {
return cdnPath(`/images/${path}`);
}

View File

@ -14,6 +14,7 @@ import enforceHttps from "koa-sslify";
import api from "./api"; import api from "./api";
import auth from "./auth"; import auth from "./auth";
import emails from "./emails"; import emails from "./emails";
import env from "./env";
import routes from "./routes"; import routes from "./routes";
import updates from "./utils/updates"; import updates from "./utils/updates";
@ -21,6 +22,24 @@ const app = new Koa();
const isProduction = process.env.NODE_ENV === "production"; const isProduction = process.env.NODE_ENV === "production";
const isTest = process.env.NODE_ENV === "test"; const isTest = process.env.NODE_ENV === "test";
// Construct scripts CSP based on services in use by this installation
const scriptSrc = [
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
"gist.github.com",
];
if (env.GOOGLE_ANALYTICS_ID) {
scriptSrc.push("www.google-analytics.com");
}
if (env.SENTRY_DSN) {
scriptSrc.push("browser.sentry-cdn.com");
}
if (env.CDN_URL) {
scriptSrc.push(env.CDN_URL);
}
app.use(compress()); app.use(compress());
if (isProduction) { if (isProduction) {
@ -149,14 +168,7 @@ app.use(
contentSecurityPolicy({ contentSecurityPolicy({
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: [ scriptSrc,
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
"gist.github.com",
"www.google-analytics.com",
"browser.sentry-cdn.com",
],
styleSrc: ["'self'", "'unsafe-inline'", "github.githubassets.com"], styleSrc: ["'self'", "'unsafe-inline'", "github.githubassets.com"],
imgSrc: ["*", "data:", "blob:"], imgSrc: ["*", "data:", "blob:"],
frameSrc: ["*"], frameSrc: ["*"],

View File

@ -3,6 +3,8 @@ import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react"; import * as React from "react";
import EmptySpace from "./EmptySpace"; import EmptySpace from "./EmptySpace";
const url = process.env.CDN_URL || process.env.URL;
export default () => { export default () => {
return ( return (
<Table width="100%"> <Table width="100%">
@ -12,7 +14,7 @@ export default () => {
<EmptySpace height={40} /> <EmptySpace height={40} />
<img <img
alt="Outline" alt="Outline"
src={`${process.env.URL}/email/header-logo.png`} src={`${url}/email/header-logo.png`}
height="48" height="48"
width="48" width="48"
/> />

View File

@ -1,6 +1,7 @@
// @flow // @flow
export default { export default {
URL: process.env.URL, URL: process.env.URL,
CDN_URL: process.env.CDN_URL || "",
DEPLOYMENT: process.env.DEPLOYMENT, DEPLOYMENT: process.env.DEPLOYMENT,
SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_DSN: process.env.SENTRY_DSN,
TEAM_LOGO: process.env.TEAM_LOGO, TEAM_LOGO: process.env.TEAM_LOGO,

View File

@ -10,7 +10,7 @@ productionWebpackConfig = Object.assign(commonWebpackConfig, {
output: { output: {
path: path.join(__dirname, 'build/app'), path: path.join(__dirname, 'build/app'),
filename: '[name].[contenthash].js', filename: '[name].[contenthash].js',
publicPath: '/static/', publicPath: `${process.env.CDN_URL || ""}/static/`,
}, },
cache: true, cache: true,
mode: "production", mode: "production",