feat: Add CDN support (#1817)
* chore: CSP * chore: Optionally use CDN for serving images
This commit is contained in:
parent
1fd2ec31fd
commit
522df125aa
@ -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
|
@ -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"));
|
||||||
|
|
||||||
|
@ -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
12
app/components/Image.js
Normal 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} />;
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
@ -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}`);
|
||||||
|
}
|
@ -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: ["*"],
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -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,
|
||||||
|
@ -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",
|
||||||
|
Reference in New Issue
Block a user