mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
[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:
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./tenant";
|
||||
export * from "./helpers";
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
import { DeepPartial, Omit, Sub } from "coral-common/types";
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import {
|
||||
GQLMODERATION_MODE,
|
||||
GQLSettings,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
createCollection,
|
||||
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");
|
||||
|
||||
/**
|
||||
* TenantResource references a given resource that should be owned by a specific
|
||||
* Tenant.
|
||||
*/
|
||||
export interface TenantResource {
|
||||
/**
|
||||
* tenantID is the reference to the specific Tenant that owns this particular
|
||||
* resource.
|
||||
*/
|
||||
readonly tenantID: string;
|
||||
}
|
||||
|
||||
export interface TenantSettings
|
||||
extends Pick<GQLSettings, "domain" | "allowedDomains" | "organization"> {
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* locale is the specified locale for this Tenant.
|
||||
*/
|
||||
locale: LanguageCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant describes a given Tenant on Coral that has Stories, Comments, and Users.
|
||||
*/
|
||||
export type Tenant = Settings & TenantSettings;
|
||||
|
||||
export async function createTenantIndexes(mongo: Db) {
|
||||
const createIndex = createIndexFactory(collection(mongo));
|
||||
|
||||
// UNIQUE { id }
|
||||
await createIndex({ id: 1 }, { unique: true });
|
||||
|
||||
// UNIQUE { domain }
|
||||
await createIndex({ domain: 1 }, { unique: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateTenantInput is the set of properties that can be set when a given
|
||||
* Tenant is created. The remainder of the properties are set from defaults and
|
||||
* are modifiable via the update method.
|
||||
*/
|
||||
export type CreateTenantInput = Pick<
|
||||
Tenant,
|
||||
"domain" | "allowedDomains" | "locale" | "organization"
|
||||
>;
|
||||
|
||||
/**
|
||||
* create will create a new Tenant.
|
||||
*
|
||||
* @param mongo the MongoDB connection used to create the tenant.
|
||||
* @param input the customizable parts of the Tenant available during creation
|
||||
*/
|
||||
export async function createTenant(
|
||||
mongo: Db,
|
||||
i18n: I18n,
|
||||
input: CreateTenantInput,
|
||||
now = new Date()
|
||||
) {
|
||||
const defaults: Sub<Tenant, CreateTenantInput> = {
|
||||
// Create a new ID.
|
||||
id: uuid.v4(),
|
||||
|
||||
// Default to post moderation.
|
||||
moderation: GQLMODERATION_MODE.POST,
|
||||
|
||||
// Default to enabled.
|
||||
live: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
communityGuidelines: {
|
||||
enabled: false,
|
||||
content: "",
|
||||
},
|
||||
premodLinksEnable: false,
|
||||
closeCommenting: {
|
||||
auto: false,
|
||||
// 2 weeks timeout.
|
||||
timeout: 60 * 60 * 24 * 7 * 2,
|
||||
},
|
||||
disableCommenting: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
// 30 seconds edit window length.
|
||||
editCommentWindowLength: 30,
|
||||
|
||||
charCount: {
|
||||
enabled: false,
|
||||
},
|
||||
wordList: {
|
||||
suspect: [],
|
||||
banned: [],
|
||||
},
|
||||
auth: {
|
||||
integrations: {
|
||||
local: {
|
||||
enabled: true,
|
||||
allowRegistration: true,
|
||||
targetFilter: {
|
||||
admin: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
sso: {
|
||||
enabled: false,
|
||||
allowRegistration: false,
|
||||
targetFilter: {
|
||||
admin: true,
|
||||
stream: true,
|
||||
},
|
||||
key: generateSSOKey(),
|
||||
keyGeneratedAt: now,
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
allowRegistration: false,
|
||||
targetFilter: {
|
||||
admin: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
google: {
|
||||
enabled: false,
|
||||
allowRegistration: false,
|
||||
targetFilter: {
|
||||
admin: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
facebook: {
|
||||
enabled: false,
|
||||
allowRegistration: false,
|
||||
targetFilter: {
|
||||
admin: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
email: {
|
||||
enabled: false,
|
||||
smtp: {},
|
||||
},
|
||||
recentCommentHistory: {
|
||||
enabled: false,
|
||||
// 7 days in seconds.
|
||||
timeFrame: 604800,
|
||||
// Rejection rate defaulting to 30%, once exceeded, comments will be
|
||||
// pre-moderated.
|
||||
triggerRejectionRate: 0.3,
|
||||
},
|
||||
integrations: {
|
||||
akismet: {
|
||||
enabled: false,
|
||||
},
|
||||
perspective: {
|
||||
enabled: false,
|
||||
doNotStore: true,
|
||||
},
|
||||
},
|
||||
reaction: getDefaultReactionConfiguration(i18n.getBundle(input.locale)),
|
||||
stories: {
|
||||
scraping: {
|
||||
enabled: true,
|
||||
},
|
||||
disableLazy: false,
|
||||
},
|
||||
accountFeatures: {
|
||||
changeUsername: false,
|
||||
deleteAccount: false,
|
||||
downloadComments: false,
|
||||
},
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Create the new Tenant by merging it together with the defaults.
|
||||
const tenant: Readonly<Tenant> = {
|
||||
...defaults,
|
||||
...input,
|
||||
};
|
||||
|
||||
// Insert the Tenant into the database.
|
||||
await collection(mongo).insert(tenant);
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
export async function retrieveTenantByDomain(mongo: Db, domain: string) {
|
||||
return collection(mongo).findOne({ domain });
|
||||
}
|
||||
|
||||
export async function retrieveTenant(mongo: Db, id: string) {
|
||||
return collection(mongo).findOne({ id });
|
||||
}
|
||||
|
||||
export async function retrieveManyTenants(mongo: Db, ids: string[]) {
|
||||
const cursor = await collection(mongo).find({
|
||||
id: {
|
||||
$in: ids,
|
||||
},
|
||||
});
|
||||
|
||||
const tenants = await cursor.toArray();
|
||||
|
||||
return ids.map(id => tenants.find(tenant => tenant.id === id) || null);
|
||||
}
|
||||
|
||||
export async function retrieveManyTenantsByDomain(
|
||||
mongo: Db,
|
||||
domains: string[]
|
||||
) {
|
||||
const cursor = await collection(mongo).find({
|
||||
domain: {
|
||||
$in: domains,
|
||||
},
|
||||
});
|
||||
|
||||
const tenants = await cursor.toArray();
|
||||
|
||||
return domains.map(
|
||||
domain => tenants.find(tenant => tenant.domain === domain) || null
|
||||
);
|
||||
}
|
||||
|
||||
export async function retrieveAllTenants(mongo: Db) {
|
||||
return collection(mongo)
|
||||
.find({})
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export async function countTenants(mongo: Db) {
|
||||
return collection(mongo)
|
||||
.find({})
|
||||
.count();
|
||||
}
|
||||
|
||||
export type UpdateTenantInput = Omit<DeepPartial<Tenant>, "id" | "domain">;
|
||||
|
||||
export async function updateTenant(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
update: UpdateTenantInput
|
||||
) {
|
||||
// Get the tenant from the database.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
// Only update fields that have been updated.
|
||||
{ $set: dotize(update, { embedArrays: true }) },
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* regenerateTenantSSOKey will regenerate the SSO key used for Single Sing-On
|
||||
* for the specified Tenant. All existing user sessions signed with the old
|
||||
* secret will be invalidated.
|
||||
*/
|
||||
export async function regenerateTenantSSOKey(mongo: Db, id: string) {
|
||||
// Construct the update.
|
||||
const update: DeepPartial<Tenant> = {
|
||||
auth: {
|
||||
integrations: {
|
||||
sso: {
|
||||
key: generateSSOKey(),
|
||||
keyGeneratedAt: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Update the Tenant with this new key.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
// Serialize the deep update into the Tenant.
|
||||
{
|
||||
$set: dotize(update),
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
Reference in New Issue
Block a user