diff --git a/package-lock.json b/package-lock.json index efb63a2fc..6dde64c72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1772,6 +1772,15 @@ "integrity": "sha512-p+gNRe4RPjpl1lTBUomFJ42P8ymArH/P93DFJ0iY873BJ4ZmogcKc6TbHgZQmtQMsy3jxcAo0HcTjidXwo8uKg==", "dev": true }, + "@types/cors": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.4.tgz", + "integrity": "sha512-ipZjBVsm2tF/n8qFGOuGBkUij9X9ZswVi9G3bx/6dz7POpVa6gVHcj1wsX/LVEn9MMF41fxK/PnZPPoTD1UFPw==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/cross-spawn": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.0.tgz", @@ -6861,6 +6870,15 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, + "cors": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.4.tgz", + "integrity": "sha1-K9OB8usgECAQXNUOpZ2mMJBpRoY=", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "cosmiconfig": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-3.1.0.tgz", diff --git a/package.json b/package.json index 385167547..357751253 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "cheerio": "^1.0.0-rc.2", "consolidate": "0.14.0", "convict": "^4.3.1", + "cors": "^2.8.4", "dataloader": "^1.4.0", "dotenv": "^6.0.0", "dotenv-expand": "^4.2.0", @@ -119,6 +120,7 @@ "@types/compression-webpack-plugin": "^0.4.2", "@types/consolidate": "0.0.34", "@types/convict": "^4.2.0", + "@types/cors": "^2.8.4", "@types/cross-spawn": "^6.0.0", "@types/dompurify": "0.0.31", "@types/dotenv": "^4.0.3", diff --git a/src/core/build/createWebpackConfig.ts b/src/core/build/createWebpackConfig.ts index 8e0b32ddd..5f331d4f2 100644 --- a/src/core/build/createWebpackConfig.ts +++ b/src/core/build/createWebpackConfig.ts @@ -9,6 +9,7 @@ import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin"; import UglifyJsPlugin from "uglifyjs-webpack-plugin"; import webpack, { Configuration } from "webpack"; import ManifestPlugin from "webpack-manifest-plugin"; +import PublicURIWebpackPlugin from "./plugins/PublicURIWebpackPlugin"; import paths from "./paths"; @@ -39,6 +40,13 @@ export default function createWebpackConfig({ const isProduction = env.NODE_ENV === "production"; + /** + * ifProduction will only include the nodes if we're in production mode. + */ + const ifProduction = isProduction + ? (...nodes: T[]) => nodes + : (...nodes: T[]) => []; + const htmlWebpackConfig: Options = { minify: isProduction && { removeComments: true, @@ -438,12 +446,14 @@ export default function createWebpackConfig({ stream: [ // We ship polyfills by default paths.appPolyfill, + ...ifProduction(paths.appPublicPath), ...devServerEntries, paths.appStreamIndex, ], auth: [ // We ship polyfills by default paths.appPolyfill, + ...ifProduction(paths.appPublicPath), ...devServerEntries, paths.appAuthIndex, // Remove deactivated entries. @@ -451,12 +461,14 @@ export default function createWebpackConfig({ install: [ // We ship polyfills by default paths.appPolyfill, + ...ifProduction(paths.appPublicPath), ...devServerEntries, paths.appInstallIndex, ], admin: [ // We ship polyfills by default paths.appPolyfill, + ...ifProduction(paths.appPublicPath), ...devServerEntries, paths.appAdminIndex, ], @@ -495,6 +507,15 @@ export default function createWebpackConfig({ inject: "body", ...htmlWebpackConfig, }), + ...ifProduction( + // Inject the pieces we need here to resolve all the now relative url's + // against the CDN if it's provided. It will inject the following into + // the configuration blob on the page. + new PublicURIWebpackPlugin( + "{{ staticURI | dump | safe }}", + "{{ staticURI }}" + ) + ), // Makes some environment variables available in index.html. // The public URL is available as %PUBLIC_URL% in index.html, e.g.: // diff --git a/src/core/build/paths.ts b/src/core/build/paths.ts index 937f3da1c..d89eb3362 100644 --- a/src/core/build/paths.ts +++ b/src/core/build/paths.ts @@ -17,6 +17,7 @@ export default { appSrc: resolveSrc("."), appTsconfig: resolveSrc("core/client/tsconfig.json"), appPolyfill: resolveSrc("core/build/polyfills.js"), + appPublicPath: resolveSrc("core/build/publicPath.js"), appLocales: resolveSrc("locales"), appThemeVariables: resolveSrc("core/client/ui/theme/variables.ts"), appThemeVariablesCSS: resolveSrc("core/client/ui/theme/variables.css"), diff --git a/src/core/build/plugins/PublicURIWebpackPlugin.ts b/src/core/build/plugins/PublicURIWebpackPlugin.ts new file mode 100644 index 000000000..4edab48eb --- /dev/null +++ b/src/core/build/plugins/PublicURIWebpackPlugin.ts @@ -0,0 +1,60 @@ +import { Hooks } from "html-webpack-plugin"; +import { Compiler, Plugin } from "webpack"; + +export default class PublicURIWebpackPlugin implements Plugin { + private configTemplate: string; + private prefixTemplate: string; + + constructor(configTemplate: string, prefixTemplate: string) { + this.configTemplate = configTemplate; + this.prefixTemplate = prefixTemplate; + } + + private prefixAttribute(attr: string | boolean) { + if (!attr || typeof attr !== "string" || !attr.startsWith("/")) { + return attr; + } + + return this.prefixTemplate + attr; + } + + private prefixTag = (tag: { + tagName: string; + attributes: Record; + }) => { + switch (tag.tagName) { + case "link": + tag.attributes.href = this.prefixAttribute(tag.attributes.href); + break; + case "script": + tag.attributes.src = this.prefixAttribute(tag.attributes.src); + break; + } + }; + + public apply = (compiler: Compiler) => { + compiler.hooks.compilation.tap("CDNWebpackPlugin", compilation => { + (compilation.hooks as Hooks).htmlWebpackPluginAlterAssetTags.tapAsync( + "CDNWebpackPlugin", + (htmlPluginData, cb) => { + // Prefix all the asset's url's with the template. + htmlPluginData.head.forEach(this.prefixTag); + htmlPluginData.body.forEach(this.prefixTag); + + // Insert the public path reference. + htmlPluginData.body.unshift({ + tagName: "script", + attributes: { + type: "application/json", + id: "config", + }, + innerHTML: this.configTemplate, + voidTag: false, + }); + + return cb(null, htmlPluginData); + } + ); + }); + }; +} diff --git a/src/core/build/publicPath.js b/src/core/build/publicPath.js new file mode 100644 index 000000000..e3e6fd334 --- /dev/null +++ b/src/core/build/publicPath.js @@ -0,0 +1,3 @@ +__webpack_public_path__ = JSON.parse( + document.getElementById("config").innerText +); diff --git a/src/core/client/ui/shared/buttonReset.css b/src/core/client/ui/shared/buttonReset.css index e03e296a3..f6d3d5517 100644 --- a/src/core/client/ui/shared/buttonReset.css +++ b/src/core/client/ui/shared/buttonReset.css @@ -1,5 +1,4 @@ .buttonReset { - /* reset button */ user-select: none; font-family: inherit; @@ -28,7 +27,6 @@ } &:-moz-focusring { - color: transparent; textshadow: 0 0 0 #000; } } diff --git a/src/core/common/config.ts b/src/core/common/config.ts index e02e44558..1ae579a03 100644 --- a/src/core/common/config.ts +++ b/src/core/common/config.ts @@ -27,6 +27,20 @@ convict.addFormat({ }, }); +// Add a custom format for the optional-url. +convict.addFormat({ + name: "optional-url", + validate: (url: string) => { + if (url) { + Joi.assert(url, Joi.string().uri()); + } + }, + // Ensure that there is no ending slash. + coerce: (url: string) => { + return url.replace(/\/$/, ""); + }, +}); + const config = convict({ env: { doc: "The application environment.", @@ -54,6 +68,7 @@ const config = convict({ default: "mongodb://127.0.0.1:27017/talk", env: "MONGODB_URI", arg: "mongodb", + sensitive: true, }, redis: { doc: "The Redis database to connect to.", @@ -61,6 +76,7 @@ const config = convict({ default: "redis://127.0.0.1:6379", env: "REDIS_URI", arg: "redis", + sensitive: true, }, signing_secret: { doc: "", @@ -68,6 +84,7 @@ const config = convict({ default: "keyboard cat", // TODO: (wyattjoh) evaluate best solution env: "SIGNING_SECRET", arg: "signingSecret", + sensitive: true, }, signing_algorithm: { doc: "", @@ -93,6 +110,13 @@ const config = convict({ env: "LOGGING_LEVEL", arg: "logging", }, + static_uri: { + doc: "The URL that static assets will be hosted from", + format: "optional-url", + default: "", + env: "STATIC_URI", + arg: "staticUri", + }, disable_tenant_caching: { doc: "Disables the tenant caching, all tenants will be loaded from MongoDB each time it's needed", diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index baaae0a9c..c4ae82ab3 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -1,4 +1,5 @@ import cons from "consolidate"; +import cors from "cors"; import { Express } from "express"; import http from "http"; import { Redis } from "ioredis"; @@ -56,6 +57,9 @@ export async function createApp(options: AppOptions): Promise { }) ); + // Enable CORS headers for media assets, font's require them. + parent.use("/assets/media", cors()); + // Static Files parent.use("/assets", cacheHeadersMiddleware("1w"), serveStatic); diff --git a/src/core/server/app/middleware/cacheHeaders.ts b/src/core/server/app/middleware/cacheHeaders.ts index f11bfd901..bc6731169 100644 --- a/src/core/server/app/middleware/cacheHeaders.ts +++ b/src/core/server/app/middleware/cacheHeaders.ts @@ -1,7 +1,7 @@ import { RequestHandler } from "express"; import ms from "ms"; -export const nocacheMiddleware: RequestHandler = (req, res, next) => { +export const noCacheMiddleware: RequestHandler = (req, res, next) => { // Set cache control headers to prevent browsers/cdn's from caching these // requests. res.set({ "Cache-Control": "no-cache, no-store, must-revalidate" }); @@ -9,10 +9,12 @@ export const nocacheMiddleware: RequestHandler = (req, res, next) => { next(); }; -export const cacheHeadersMiddleware = (duration?: string): RequestHandler => { +export const cacheHeadersMiddleware = ( + duration?: string | false +): RequestHandler => { const maxAge = duration ? Math.floor(ms(duration) / 1000) : false; if (!maxAge) { - return nocacheMiddleware; + return noCacheMiddleware; } return (req, res, next) => { diff --git a/src/core/server/app/router/client.ts b/src/core/server/app/router/client.ts index d75e3ad75..9b7cab8c2 100644 --- a/src/core/server/app/router/client.ts +++ b/src/core/server/app/router/client.ts @@ -11,10 +11,17 @@ export interface ClientTargetHandlerOptions { /** * cacheDuration is the cache duration that a given request should be cached for. */ - cacheDuration?: string; + cacheDuration?: string | false; + + /** + * staticURI is prepended to the static url's that are included on the static + * pages. + */ + staticURI: string; } export function createClientTargetRouter({ + staticURI, view, cacheDuration = "1h", }: ClientTargetHandlerOptions) { @@ -22,7 +29,7 @@ export function createClientTargetRouter({ const router = express.Router(); router.get("/", cacheHeadersMiddleware(cacheDuration), (req, res) => - res.render(view) + res.render(view, { staticURI }) ); return router; diff --git a/src/core/server/app/router/index.ts b/src/core/server/app/router/index.ts index 57bda4b0e..27444b3c5 100644 --- a/src/core/server/app/router/index.ts +++ b/src/core/server/app/router/index.ts @@ -1,7 +1,7 @@ import express, { Router } from "express"; import { AppOptions } from "talk-server/app"; -import { nocacheMiddleware } from "talk-server/app/middleware/cacheHeaders"; +import { noCacheMiddleware } from "talk-server/app/middleware/cacheHeaders"; import { installedMiddleware } from "talk-server/app/middleware/installed"; import playground from "talk-server/app/middleware/playground"; import { RouterOptions } from "talk-server/app/router/types"; @@ -14,42 +14,56 @@ export async function createRouter(app: AppOptions, options: RouterOptions) { // Create a router. const router = express.Router(); - router.use("/api", nocacheMiddleware, await createAPIRouter(app, options)); + router.use("/api", noCacheMiddleware, await createAPIRouter(app, options)); // Attach the GraphiQL if enabled. if (app.config.get("enable_graphiql")) { attachGraphiQL(router, app); } + const staticURI = app.config.get("static_uri"); + // Add the embed targets. - router.use("/embed/stream", createClientTargetRouter({ view: "stream" })); - router.use("/embed/auth", createClientTargetRouter({ view: "auth" })); + router.use( + "/embed/stream", + createClientTargetRouter({ staticURI, view: "stream" }) + ); + router.use( + "/embed/auth", + createClientTargetRouter({ staticURI, view: "auth", cacheDuration: false }) + ); // Add the standalone targets. router.use( "/admin", + // If we aren't already installed, redirect the user to the install page. installedMiddleware({ tenantCache: app.tenantCache, }), - createClientTargetRouter({ view: "admin" }) + createClientTargetRouter({ staticURI, view: "admin", cacheDuration: false }) ); router.use( "/install", + // If we're already installed, redirect the user to the admin page. installedMiddleware({ tenantCache: app.tenantCache, redirectIfInstalled: true, redirectURL: "/admin", }), - createClientTargetRouter({ view: "install", cacheDuration: "" }) + createClientTargetRouter({ + staticURI, + view: "install", + cacheDuration: false, + }) ); // Handle the root path. router.get( "/", + // Redirect the user to the install page if they are not, otherwise redirect + // them to the admin. installedMiddleware({ tenantCache: app.tenantCache }), - (req, res, next) => { - res.redirect("/admin"); - } + (req, res, next) => res.redirect("/admin") ); return router; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 5fbda5731..7529c2be7 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -38,9 +38,12 @@ class Server { constructor(options: ServerOptions) { this.parentApp = express(); + + // Load the configuration. this.config = config .load(options.config || {}) .validate({ allowed: "strict" }); + logger.debug({ config: this.config.toString() }, "loaded configuration"); // Load the graph schemas. this.schemas = {