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
REDIS_URL=redis://localhost:6479
# Must point to the publicly accessible URL for the installation
URL=http://localhost: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.
# set to false if your SSL is terminated at a loadbalancer, for example
FORCE_HTTPS=true
@ -66,4 +71,6 @@ SMTP_REPLY_EMAIL=
# Custom logo that displays on the authentication screen, scaled to height: 60px
# 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

View File

@ -8,9 +8,9 @@ import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import isInternalUrl from "utils/isInternalUrl";
import { isMetaKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
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 DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import isInternalUrl from "utils/isInternalUrl";
import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 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
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
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,
favicon?: string,
auth: AuthStore,
};
|};
const PageTitle = observer(({ auth, title, favicon }: Props) => {
const PageTitle = ({ title, favicon }: Props) => {
const { auth } = useStores();
const { team } = auth;
return (
@ -21,12 +22,12 @@ const PageTitle = observer(({ auth, title, favicon }: Props) => {
<link
rel="shortcut icon"
type="image/png"
href={favicon || "/favicon-32.png"}
href={favicon || cdnPath("/favicon-32.png")}
sizes="32x32"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Helmet>
);
});
};
export default inject("auth")(PageTitle);
export default observer(PageTitle);

View File

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

View File

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

View File

@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
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}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img
<Image
src="/images/google-sheets.png"
alt="Google Sheets Icon"
width={16}

View File

@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
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("/pub", "/embed")}
icon={
<img
<Image
src="/images/google-slides.png"
alt="Google Slides Icon"
width={16}

View File

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

View File

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

View File

@ -1,7 +1,8 @@
// @flow
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;
const outline = parseDomain(window.location.href);
@ -19,3 +20,11 @@ export default function isInternalUrl(href: string) {
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 auth from "./auth";
import emails from "./emails";
import env from "./env";
import routes from "./routes";
import updates from "./utils/updates";
@ -21,6 +22,24 @@ const app = new Koa();
const isProduction = process.env.NODE_ENV === "production";
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());
if (isProduction) {
@ -149,14 +168,7 @@ app.use(
contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
"gist.github.com",
"www.google-analytics.com",
"browser.sentry-cdn.com",
],
scriptSrc,
styleSrc: ["'self'", "'unsafe-inline'", "github.githubassets.com"],
imgSrc: ["*", "data:", "blob:"],
frameSrc: ["*"],

View File

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

View File

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

View File

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