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
|
||||
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
|
@ -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"));
|
||||
|
||||
|
@ -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
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
|
||||
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);
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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}`);
|
||||
}
|
@ -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: ["*"],
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
Reference in New Issue
Block a user