[CORL-406] Tenant Locale Selection (#2450)

* feat: added preload config

* feat: support changing locale

* fix: name case

* fix: removed unused code

* feat: added translations for default reactions

* fix: do not translate icon

* fix: shorter i18n keys
This commit is contained in:
Wyatt Johnson
2019-09-05 17:02:06 +00:00
committed by GitHub
parent 5bf4f22931
commit 04c56b3fb5
54 changed files with 673 additions and 207 deletions
+3 -1
View File
@@ -54,13 +54,14 @@ const TenantInstallBodySchema = Joi.object().keys({
export type TenantInstallHandlerOptions = Pick<
AppOptions,
"redis" | "mongo" | "config" | "mailerQueue"
"redis" | "mongo" | "config" | "mailerQueue" | "i18n"
>;
export const installHandler = ({
mongo,
redis,
config,
i18n,
}: TenantInstallHandlerOptions): RequestHandler => async (req, res, next) => {
try {
if (!req.coral) {
@@ -95,6 +96,7 @@ export const installHandler = ({
mongo,
redis,
req.coral.cache.tenant,
i18n,
{
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
+51 -27
View File
@@ -2,16 +2,20 @@ import express, { Router } from "express";
import { minify } from "html-minifier";
import path from "path";
import { LanguageCode } from "coral-common/helpers/i18n/locales";
import { cacheHeadersMiddleware } from "coral-server/app/middleware/cacheHeaders";
import { cspTenantMiddleware } from "coral-server/app/middleware/csp/tenant";
import { installedMiddleware } from "coral-server/app/middleware/installed";
import { tenantMiddleware } from "coral-server/app/middleware/tenant";
import logger from "coral-server/logger";
import TenantCache from "coral-server/services/tenant/cache";
import { RequestHandler } from "coral-server/types/express";
import Entrypoints, { Entrypoint } from "../helpers/entrypoints";
export interface ClientTargetHandlerOptions {
defaultLocale: LanguageCode;
/**
* entrypoint is the entrypoint entry to load.
*/
@@ -35,50 +39,64 @@ export interface ClientTargetHandlerOptions {
staticURI: string;
}
function createClientTargetRouter({
staticURI,
entrypoint,
enableCustomCSS = false,
cacheDuration = "1h",
}: ClientTargetHandlerOptions) {
function createClientTargetRouter(options: ClientTargetHandlerOptions) {
// Create a router.
const router = express.Router();
// Always send the cache headers.
router.use(cacheHeadersMiddleware(cacheDuration));
router.use(cacheHeadersMiddleware(options.cacheDuration));
// Wildcard display all the client routes under the provided prefix.
router.get("/*", (req, res, next) =>
res.render(
"client",
{ staticURI, entrypoint, enableCustomCSS },
(err, html) => {
if (err) {
return next(err);
}
// Send back the HTML minified.
res.send(
minify(html, {
removeComments: true,
collapseWhitespace: true,
})
);
}
)
);
router.get("/*", clientHandler(options));
return router;
}
interface MountClientRouteOptions {
defaultLocale: LanguageCode;
tenantCache: TenantCache;
staticURI: string;
}
const clientHandler = ({
staticURI,
entrypoint,
enableCustomCSS,
defaultLocale,
}: ClientTargetHandlerOptions): RequestHandler => (req, res, next) => {
// Provide configuration to the frontend in the HTML.
const config = {
staticURI,
};
// Grab the locale code from the tenant configuration, if available.
let locale: LanguageCode = defaultLocale;
if (req.coral && req.coral.tenant) {
locale = req.coral.tenant.locale;
}
res.render(
"client",
{ staticURI, entrypoint, enableCustomCSS, locale, config },
(err, html) => {
if (err) {
return next(err);
}
// Send back the HTML minified.
res.send(
minify(html, {
removeComments: true,
collapseWhitespace: true,
})
);
}
);
};
export function mountClientRoutes(
router: Router,
{ staticURI, tenantCache }: MountClientRouteOptions
{ staticURI, tenantCache, defaultLocale }: MountClientRouteOptions
) {
// TODO: (wyattjoh) figure out a better way of referencing paths.
// Load the entrypoint manifest.
@@ -119,6 +137,7 @@ export function mountClientRoutes(
staticURI,
enableCustomCSS: true,
entrypoint: entrypoints.get("stream"),
defaultLocale,
})
);
router.use(
@@ -127,6 +146,7 @@ export function mountClientRoutes(
staticURI,
cacheDuration: false,
entrypoint: entrypoints.get("authCallback"),
defaultLocale,
})
);
router.use(
@@ -135,6 +155,7 @@ export function mountClientRoutes(
staticURI,
cacheDuration: false,
entrypoint: entrypoints.get("auth"),
defaultLocale,
})
);
@@ -147,6 +168,7 @@ export function mountClientRoutes(
staticURI,
cacheDuration: false,
entrypoint: entrypoints.get("account"),
defaultLocale,
})
);
// Add the standalone targets.
@@ -158,6 +180,7 @@ export function mountClientRoutes(
staticURI,
cacheDuration: false,
entrypoint: entrypoints.get("admin"),
defaultLocale,
})
);
router.use(
@@ -171,6 +194,7 @@ export function mountClientRoutes(
staticURI,
cacheDuration: false,
entrypoint: entrypoints.get("install"),
defaultLocale,
})
);
+2
View File
@@ -2,6 +2,7 @@ import cookies from "cookie-parser";
import express, { Router } from "express";
import { register } from "prom-client";
import { LanguageCode } from "coral-common/helpers/i18n/locales";
import { AppOptions } from "coral-server/app";
import { noCacheMiddleware } from "coral-server/app/middleware/cacheHeaders";
import playground from "coral-server/app/middleware/playground";
@@ -31,6 +32,7 @@ export function createRouter(app: AppOptions, options: RouterOptions) {
if (!options.disableClientRoutes) {
mountClientRoutes(router, {
defaultLocale: app.config.get("default_locale") as LanguageCode,
// When mounting client routes, we need to provide a staticURI even when
// not provided to the default current domain relative "/".
staticURI: app.config.get("static_uri") || "/",
+22 -4
View File
@@ -4,8 +4,24 @@
{% block title %}Coral{% endblock %}
{% block meta %}
<script type="application/javascript" id="config">
{{ staticURI | dump | safe }}
{# Insert the link preload tags here. #}
{% if entrypoint.css %}
{% for asset in entrypoint.css %}
{{ macros.preload(asset.src, "style", prefix = staticURI) }}
{% endfor %}
{% endif %}
{% if enableCustomCSS and tenant and tenant.customCSSURL %}
{{ macros.preload(tenant.customCSSURL, "style", crossorigin = true) }}
{% endif %}
{% if entrypoint.js %}
{% for asset in entrypoint.js %}
{{ macros.preload(asset.src, "script", prefix = staticURI) }}
{% endfor %}
{% endif %}
{# Insert the staticURI via the configuration object during insertion. #}
<script type="application/json" id="config">
{{ config | dump | safe }}
</script>
{% endblock %}
@@ -18,9 +34,11 @@
{% endfor %}
{% endif %}
{# Custom CSS is included after the CSS block so that its overrides will apply #}
{% if enableCustomCSS %}
{# Custom CSS is included after the CSS block so that its overrides will apply #}
{% include "partials/customCSS.html" %}
{% if tenant and tenant.customCSSURL %}
{{ macros.css(tenant.customCSSURL) }}
{% endif %}
{% endif %}
{% endblock %}
{% endif %}
+10 -2
View File
@@ -1,4 +1,12 @@
{% macro css(src, integrity = '', prefix = '') %}
{% macro preload(src, htmlAs, prefix = "", crossorigin = false) %}
{% if crossorigin %}
<link rel="preload" href="{{ prefix }}{{ src }}" as="{{ htmlAs }}" crossorigin>
{% else %}
<link rel="preload" href="{{ prefix }}{{ src }}" as="{{ htmlAs }}">
{% endif %}
{% endmacro %}
{% macro css(src, integrity = "", prefix = "") %}
<link type="text/css" rel="stylesheet" href="{{ prefix }}{{ src }}"/>
{# TODO: evaluate when to enable SRI, non-SSL connections cause issues #}
{# {% if integrity %}
@@ -8,7 +16,7 @@
{% endif %} #}
{% endmacro %}
{% macro js(src, integrity = '', prefix = '') %}
{% macro js(src, integrity = "", prefix = "") %}
<script type="application/javascript" src="{{ prefix }}{{ src }}"></script>
{# TODO: evaluate when to enable SRI, non-SSL connections cause issues #}
{# {% if false %}
@@ -1,5 +0,0 @@
{% import "../macros.html" as macros %}
{% if tenant and tenant.customCSSURL %}
{{ macros.css(tenant.customCSSURL) }}
{% endif %}
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="{{ locale | default('en-US', true) }}">
<head>
{# Meta tags #}
<meta charset="utf-8" />
@@ -0,0 +1,43 @@
import { Kind } from "graphql";
import Locale from "./locale";
describe("parseLiteral", () => {
it("parses a valid locale from a string", () => {
expect(
Locale.parseLiteral({
kind: Kind.STRING,
value: "en-US",
})
).toBe("en-US");
});
it("parses an unsupported locale from a string", () => {
expect(() =>
Locale.parseLiteral({
kind: Kind.STRING,
value: "xyz",
})
).toThrow();
});
it("throws when not a string", () => {
expect(() =>
Locale.parseLiteral({
kind: Kind.INT,
value: "4",
})
).toThrow();
});
});
describe("parseValue", () => {
it("parses a valid locale from a string", () => {
expect(Locale.parseValue("en-US")).toBe("en-US");
});
it("parses an unsupported locale from a string", () => {
expect(() => Locale.parseValue("xyz")).toThrow();
});
it("throws when not a string", () => {
expect(() => Locale.parseValue(4)).toThrow();
});
});
@@ -0,0 +1,32 @@
import { LOCALES } from "coral-common/helpers/i18n";
import { GraphQLScalarType } from "graphql";
import { Kind } from "graphql/language";
function assertSupportLocale(locale: string) {
if (!LOCALES.includes(locale as any)) {
throw new Error(`Supported locales are ${JSON.stringify(LOCALES)}`);
}
}
export default new GraphQLScalarType({
name: "Locale",
description: "Locale represents a language code in the BCP 47 format.",
serialize(value) {
return value;
},
parseValue(value) {
if (typeof value !== "string") {
throw new Error("Locale must be a string in BCP 47 format.");
}
assertSupportLocale(value);
return value;
},
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new Error("Locale must be a string in BCP 47 format.");
}
const value = ast.value.toString();
assertSupportLocale(value);
return value;
},
});
@@ -1,28 +0,0 @@
import { LanguageCode } from "coral-common/helpers/i18n/locales";
import { GQLLOCALES } from "../schema/__generated__/types";
import { LOCALES } from "./LOCALES";
it("does not contain duplicate entries", () => {
const seen: Partial<Record<LanguageCode, true>> = {};
for (const key in LOCALES) {
if (!LOCALES.hasOwnProperty(key)) {
continue;
}
const value = LOCALES[key as GQLLOCALES];
expect(value in seen).toBeFalsy();
seen[value] = true;
}
});
it("contains the correct mappings to the BCP 47 format", () => {
for (const key in LOCALES) {
if (!LOCALES.hasOwnProperty(key)) {
continue;
}
const value = LOCALES[key as GQLLOCALES];
expect(value).toEqual(key.replace(/_/, "-"));
}
});
@@ -1,10 +0,0 @@
import { LanguageCode } from "coral-common/helpers/i18n/locales";
import { GQLLOCALES } from "../schema/__generated__/types";
export const LOCALES: Record<GQLLOCALES, LanguageCode> = {
en_US: "en-US",
pt_BR: "pt-BR",
es: "es",
de: "de",
};
@@ -1,4 +1,5 @@
import Cursor from "coral-server/graph/common/scalars/cursor";
import Locale from "coral-server/graph/common/scalars/locale";
import Time from "coral-server/graph/common/scalars/time";
import { GQLResolver } from "coral-server/graph/tenant/schema/__generated__/types";
@@ -81,6 +82,7 @@ const Resolvers: GQLResolver = {
UsernameHistory,
Tag,
Time,
Locale,
User,
UserStatus,
UsernameStatus,
+1 -17
View File
@@ -1,14 +1,8 @@
import {
addResolveFunctionsToSchema,
attachDirectiveResolvers,
IEnumResolver,
IResolvers,
} from "graphql-tools";
import { attachDirectiveResolvers, IResolvers } from "graphql-tools";
import { loadSchema } from "coral-common/graphql";
import auth from "coral-server/graph/common/directives/auth";
import resolvers from "coral-server/graph/tenant/resolvers";
import { LOCALES } from "coral-server/graph/tenant/resolvers/LOCALES";
export default function getTenantSchema() {
const schema = loadSchema("tenant", resolvers as IResolvers);
@@ -16,15 +10,5 @@ export default function getTenantSchema() {
// Attach the directive resolvers.
attachDirectiveResolvers(schema, { auth });
// Attach the GraphQL enum fields.
addResolveFunctionsToSchema({
schema,
resolvers: {
// For some reason, the resolver doesn't quite work without coercing the
// type.
LOCALES: LOCALES as IEnumResolver,
},
});
return schema;
}
@@ -68,6 +68,11 @@ Cursor represents a paginating cursor.
"""
scalar Cursor
"""
Locale represents a language code in the BCP 47 format.
"""
scalar Locale
################################################################################
## Actions
################################################################################
@@ -990,17 +995,6 @@ type StoryMessageBox {
## Settings
################################################################################
"""
LOCALES list all the supported locales in a modified BCP 47 format, where the
hyphen is replaced by an underscore.
"""
enum LOCALES {
en_US
pt_BR
es
de
}
"""
CloseCommenting contains settings related to the automatic closing of commenting
on Stories.
@@ -1124,7 +1118,7 @@ type Settings {
"""
locale is the specified locale for this Tenant.
"""
locale: LOCALES!
locale: Locale!
"""
live provides configuration options related to live updates for stories on
@@ -3371,6 +3365,11 @@ input SettingsInput {
accountFeatures specifies the configuration for accounts.
"""
accountFeatures: CommenterAccountFeaturesInput
"""
locale specifies the locale for this Tenant.
"""
locale: Locale
}
"""
+4
View File
@@ -1,2 +1,6 @@
closeCommentingDefaultMessage = Comments are closed on this story.
disableCommentingDefaultMessage = Comments are closed on this story.
reaction-labelRespect = Respect
reaction-labelActiveRespected = Respected
reaction-sortLabelMostRespected = Most Respected
+28
View File
@@ -0,0 +1,28 @@
import crypto from "crypto";
import { FluentBundle } from "fluent/compat";
import { GQLReactionConfiguration } from "coral-server/graph/tenant/schema/__generated__/types";
import { translate } from "coral-server/services/i18n";
export const getDefaultReactionConfiguration = (
bundle: FluentBundle
): GQLReactionConfiguration => ({
// By default, the standard reaction style will use the Respect with the
// handshake.
label: translate(bundle, "Respect", "reaction-labelRespect"),
labelActive: translate(bundle, "Respected", "reaction-labelActiveRespected"),
sortLabel: translate(
bundle,
"Most Respected",
"reaction-sortLabelMostRespected"
),
icon: "thumb_up",
});
export function generateSSOKey() {
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
// as 16 was the minimum length recommended.
//
// Reference: https://security.stackexchange.com/a/96176
return crypto.randomBytes(32 + Math.floor(Math.random() * 5)).toString("hex");
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./tenant";
export * from "./helpers";
@@ -1,4 +1,3 @@
import crypto from "crypto";
import { Db } from "mongodb";
import uuid from "uuid";
@@ -14,6 +13,8 @@ import {
createIndexFactory,
} from "coral-server/models/helpers";
import { Settings } from "coral-server/models/settings";
import { I18n } from "coral-server/services/i18n";
import { generateSSOKey, getDefaultReactionConfiguration } from "./helpers";
const collection = createCollection<Tenant>("tenants");
@@ -72,6 +73,7 @@ export type CreateTenantInput = Pick<
*/
export async function createTenant(
mongo: Db,
i18n: I18n,
input: CreateTenantInput,
now = new Date()
) {
@@ -178,14 +180,7 @@ export async function createTenant(
doNotStore: true,
},
},
reaction: {
// By default, the standard reaction style will use the Respect with the
// handshake.
label: "Respect",
labelActive: "Respected",
sortLabel: "Most Respected",
icon: "thumb_up",
},
reaction: getDefaultReactionConfiguration(i18n.getBundle(input.locale)),
stories: {
scraping: {
enabled: true,
@@ -281,14 +276,6 @@ export async function updateTenant(
return result.value || null;
}
function generateSSOKey() {
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
// as 16 was the minimum length recommended.
//
// Reference: https://security.stackexchange.com/a/96176
return crypto.randomBytes(32 + Math.floor(Math.random() * 5)).toString("hex");
}
/**
* regenerateTenantSSOKey will regenerate the SSO key used for Single Sing-On
* for the specified Tenant. All existing user sessions signed with the old
+3 -3
View File
@@ -15,6 +15,7 @@ import {
Tenant,
updateTenant,
} from "coral-server/models/tenant";
import { I18n } from "coral-server/services/i18n";
import TenantCache from "./cache";
@@ -55,6 +56,7 @@ export async function install(
mongo: Db,
redis: Redis,
cache: TenantCache,
i18n: I18n,
input: InstallTenant,
now = new Date()
) {
@@ -64,12 +66,10 @@ export async function install(
// TODO: (wyattjoh) perform any pending migrations.
// TODO: (wyattjoh) setup database indexes.
logger.info({ tenant: input }, "installing tenant");
// Create the Tenant.
const tenant = await createTenant(mongo, input, now);
const tenant = await createTenant(mongo, i18n, input, now);
// Update the tenant cache.
await cache.update(redis, tenant);