[CORL-1149] Active Stories (#3040)

* fix: removed type assertions in place of stricter types

* fix: fixed bug with module ordering

* feat: support for story activity jsonp route
This commit is contained in:
Wyatt Johnson
2020-07-28 16:34:05 +00:00
committed by GitHub
parent 1f72cdbfb3
commit 6e50b28d8b
34 changed files with 382 additions and 276 deletions
+2 -2
View File
@@ -71,9 +71,9 @@ function detectAndInject(opts: DetectAndInjectArgs = {}) {
const { url, id, notext } = queryMap[ref];
// Compile the arguments used to generate the
const args: Record<string, string | number | undefined> = {
url,
const args: Record<string, string | undefined> = {
id,
url,
notext: notext ? "true" : "false",
ref,
};
@@ -15,7 +15,7 @@ import {
sendConfirmationEmail,
verifyConfirmTokenString,
} from "coral-server/services/users/auth/confirm";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
@@ -38,7 +38,7 @@ export const confirmRequestHandler = ({
mongo,
mailerQueue,
signingConfig,
}: ConfirmRequestOptions): RequestHandler => {
}: ConfirmRequestOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -59,9 +59,7 @@ export const confirmRequestHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, logger, now } = req.coral;
// Grab the requesting user.
const requestingUser = req.user;
@@ -99,7 +97,7 @@ export const confirmRequestHandler = ({
await userIDLimiter.test(req, targetUserID);
const log = coral.logger.child(
const log = logger.child(
{
targetUserID,
requestingUserID: requestingUser.id,
@@ -122,7 +120,7 @@ export const confirmRequestHandler = ({
signingConfig,
// TODO: (wyattjoh) evaluate the use of required here.
targetUser as Required<User>,
coral.now
now
);
log.trace("sent confirm email with token");
@@ -144,7 +142,7 @@ export const confirmCheckHandler = ({
mongo,
signingConfig,
config,
}: ConfirmCheckOptions): RequestHandler => {
}: ConfirmCheckOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -165,9 +163,7 @@ export const confirmCheckHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// TODO: evaluate verifying if the Tenant allows verifications to short circuit.
@@ -189,7 +185,7 @@ export const confirmCheckHandler = ({
tenant,
signingConfig,
tokenString,
coral.now
now
);
return res.sendStatus(204);
@@ -209,7 +205,7 @@ export const confirmHandler = ({
mongo,
signingConfig,
config,
}: ConfirmOptions): RequestHandler => {
}: ConfirmOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -230,9 +226,7 @@ export const confirmHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { now, tenant } = req.coral;
// Grab the token from the request.
const tokenString = extractTokenFromRequest(req, true);
@@ -247,7 +241,7 @@ export const confirmHandler = ({
}
// Execute the reset.
await confirmEmail(mongo, tenant, signingConfig, tokenString, coral.now);
await confirmEmail(mongo, tenant, signingConfig, tokenString, now);
return res.sendStatus(204);
} catch (err) {
@@ -10,7 +10,7 @@ import {
sendUserDownload,
verifyDownloadTokenString,
} from "coral-server/services/users/download";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
const USER_ID_LIMITER_TTL = "1d";
@@ -32,7 +32,7 @@ export const accountDownloadHandler = ({
redis,
signingConfig,
config,
}: AccountDownloadOptions): RequestHandler => {
}: AccountDownloadOptions): RequestHandler<TenantCoralRequest> => {
const userIDLimiter = new RequestLimiter({
redis,
ttl: USER_ID_LIMITER_TTL,
@@ -43,9 +43,7 @@ export const accountDownloadHandler = ({
return async (req, res, next) => {
try {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
@@ -71,7 +69,7 @@ export const accountDownloadHandler = ({
tenant,
signingConfig,
token,
coral.now
now
);
// Only load comments since this download token was issued.
@@ -97,7 +95,7 @@ export const accountDownloadCheckHandler = ({
redis,
signingConfig,
config,
}: AccountDownloadCheckOptions): RequestHandler => {
}: AccountDownloadCheckOptions): RequestHandler<TenantCoralRequest> => {
const userIDLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -108,9 +106,7 @@ export const accountDownloadCheckHandler = ({
return async (req, res, next) => {
try {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
const tokenString = extractTokenFromRequest(req, true);
if (!tokenString) {
@@ -130,7 +126,7 @@ export const accountDownloadCheckHandler = ({
tenant,
signingConfig,
tokenString,
coral.now
now
);
return res.sendStatus(204);
@@ -8,7 +8,7 @@ import {
redeem,
verifyInviteTokenString,
} from "coral-server/services/users/auth/invite";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
export type InviteCheckOptions = Pick<
AppOptions,
@@ -20,7 +20,7 @@ export const inviteCheckHandler = ({
signingConfig,
mongo,
config,
}: InviteCheckOptions): RequestHandler => {
}: InviteCheckOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -41,9 +41,7 @@ export const inviteCheckHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Grab the token from the request.
const tokenString = extractTokenFromRequest(req, true);
@@ -62,7 +60,7 @@ export const inviteCheckHandler = ({
tenant,
signingConfig,
tokenString,
coral.now
now
);
return res.sendStatus(204);
@@ -92,7 +90,7 @@ export const inviteHandler = ({
mongo,
signingConfig,
config,
}: InviteOptions): RequestHandler => {
}: InviteOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -113,9 +111,7 @@ export const inviteHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Grab the token from the request.
const tokenString = extractTokenFromRequest(req, true);
@@ -143,7 +139,7 @@ export const inviteHandler = ({
signingConfig,
tokenString,
{ username, password },
coral.now
now
);
return res.sendStatus(204);
@@ -3,7 +3,7 @@ import { RequestLimiter } from "coral-server/app/request/limiter";
import { updateUserNotificationSettings } from "coral-server/models/user";
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
import { verifyUnsubscribeTokenString } from "coral-server/services/notifications/categories/unsubscribe";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
export type UnsubscribeCheckOptions = Pick<
AppOptions,
@@ -15,7 +15,7 @@ export const unsubscribeCheckHandler = ({
mongo,
signingConfig,
config,
}: UnsubscribeCheckOptions): RequestHandler => {
}: UnsubscribeCheckOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -36,9 +36,7 @@ export const unsubscribeCheckHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// TODO: evaluate verifying if the Tenant allows verifications to short circuit.
@@ -60,7 +58,7 @@ export const unsubscribeCheckHandler = ({
tenant,
signingConfig,
tokenString,
coral.now
now
);
return res.sendStatus(204);
@@ -80,7 +78,7 @@ export const unsubscribeHandler = ({
mongo,
signingConfig,
config,
}: UnsubscribeOptions): RequestHandler => {
}: UnsubscribeOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -101,9 +99,7 @@ export const unsubscribeHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Grab the token from the request.
const tokenString = extractTokenFromRequest(req, true);
@@ -123,7 +119,7 @@ export const unsubscribeHandler = ({
tenant,
signingConfig,
tokenString,
coral.now
now
);
// Unsubscribe the user from all notification types.
@@ -13,7 +13,7 @@ import {
verifyResetTokenString,
} from "coral-server/services/users/auth";
import { validateEmail } from "coral-server/services/users/helpers";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
export interface ForgotBody {
email: string;
@@ -34,7 +34,7 @@ export const forgotHandler = ({
mongo,
signingConfig,
mailerQueue,
}: ForgotOptions): RequestHandler => {
}: ForgotOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -55,9 +55,7 @@ export const forgotHandler = ({
// Limit based on the IP address.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, logger, now } = req.coral;
// Check to ensure that the local integration has been enabled.
if (!hasEnabledAuthIntegration(tenant, "local")) {
@@ -75,7 +73,7 @@ export const forgotHandler = ({
// Limit based on the email address.
await emailLimiter.test(req, email);
const log = coral.logger.child(
const log = logger.child(
{
email,
tenantID: tenant.id,
@@ -102,7 +100,7 @@ export const forgotHandler = ({
config,
signingConfig,
user,
req.coral!.now
now
);
// Add the email to the processing queue.
@@ -150,7 +148,7 @@ export const forgotResetHandler = ({
mongo,
signingConfig,
config,
}: ForgotResetOptions): RequestHandler => {
}: ForgotResetOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -171,9 +169,7 @@ export const forgotResetHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Check to ensure that the local integration has been enabled.
if (!hasEnabledAuthIntegration(tenant, "local")) {
@@ -206,7 +202,7 @@ export const forgotResetHandler = ({
signingConfig,
tokenString,
password,
coral.now
now
);
return res.sendStatus(204);
@@ -226,7 +222,7 @@ export const forgotCheckHandler = ({
mongo,
signingConfig,
config,
}: ForgotCheckOptions): RequestHandler => {
}: ForgotCheckOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -247,9 +243,7 @@ export const forgotCheckHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Check to ensure that the local integration has been enabled.
if (!hasEnabledAuthIntegration(tenant, "local")) {
@@ -274,7 +268,7 @@ export const forgotCheckHandler = ({
tenant,
signingConfig,
tokenString,
coral.now
now
);
return res.sendStatus(204);
@@ -1,6 +1,6 @@
import { AppOptions } from "coral-server/app";
import { handleLogout } from "coral-server/app/middleware/passport";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
export * from "./forgot";
export * from "./signup";
@@ -10,7 +10,11 @@ export type LogoutOptions = Pick<AppOptions, "redis">;
export const logoutHandler = ({
redis,
}: LogoutOptions): RequestHandler => async (req, res, next) => {
}: LogoutOptions): RequestHandler<TenantCoralRequest> => async (
req,
res,
next
) => {
try {
// Get the user on the request.
const user = req.user;
@@ -6,7 +6,7 @@ import { RequestLimiter } from "coral-server/app/request/limiter";
import { linkUsersAvailable } from "coral-server/models/tenant";
import { signTokenString } from "coral-server/services/jwt";
import { link } from "coral-server/services/users";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
export interface LinkBody {
email: string;
@@ -28,7 +28,7 @@ export const linkHandler = ({
mongo,
signingConfig,
config,
}: LinkOptions): RequestHandler => {
}: LinkOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -42,9 +42,7 @@ export const linkHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Check to ensure that the local integration has been enabled.
if (!linkUsersAvailable(tenant)) {
@@ -62,13 +60,7 @@ export const linkHandler = ({
// Account linking is complete! Return the new access token for the
// request.
const token = await signTokenString(
signingConfig,
user,
tenant,
{},
coral.now
);
const token = await signTokenString(signingConfig, user, tenant, {}, now);
return res.json({ token });
} catch (err) {
@@ -10,7 +10,7 @@ import { hasEnabledAuthIntegration } from "coral-server/models/tenant";
import { LocalProfile, User } from "coral-server/models/user";
import { create } from "coral-server/services/users";
import { sendConfirmationEmail } from "coral-server/services/users/auth";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
@@ -37,7 +37,7 @@ export const signupHandler = ({
mongo,
signingConfig,
mailerQueue,
}: SignupOptions): RequestHandler => {
}: SignupOptions): RequestHandler<TenantCoralRequest> => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
@@ -51,9 +51,7 @@ export const signupHandler = ({
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const tenant = req.coral!.tenant!;
const now = req.coral!.now;
const { tenant, now } = req.coral;
// Check to ensure that the local integration has been enabled.
if (!hasEnabledAuthIntegration(tenant, "local")) {
@@ -19,13 +19,17 @@ import {
retrieveDailyUserMetrics,
retrieveTodayUserMetrics,
} from "coral-server/models/user/metrics";
import { Request, RequestHandler } from "coral-server/types/express";
import {
Request,
RequestHandler,
TenantCoralRequest,
} from "coral-server/types/express";
function getMetricsOptions(req: Request) {
function getMetricsOptions(req: Request<TenantCoralRequest>) {
// Get the current Tenant on the request.
const { id: tenantID } = req.coral!.tenant!;
const { id: tenantID } = req.coral.tenant;
const now = req.coral!.now;
const now = req.coral.now;
// To set a fixed date for the date, uncomment the line below.
// const now = DateTime.utc(2020, 5, 5, 12, 30).toJSDate();
@@ -46,7 +50,11 @@ function getMetricsOptions(req: Request) {
export const todayMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
}: AppOptions): RequestHandler<TenantCoralRequest> => async (
req,
res,
next
) => {
try {
const { tenantID, siteID, tz, now } = getMetricsOptions(req);
if (!siteID) {
@@ -71,7 +79,11 @@ export const todayMetricsHandler = ({
export const totalMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
}: AppOptions): RequestHandler<TenantCoralRequest> => async (
req,
res,
next
) => {
try {
const { tenantID, siteID } = getMetricsOptions(req);
if (!siteID) {
@@ -105,7 +117,11 @@ export const totalMetricsHandler = ({
export const hourlyCommentsMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
}: AppOptions): RequestHandler<TenantCoralRequest> => async (
req,
res,
next
) => {
try {
const { tenantID, siteID, tz, now } = getMetricsOptions(req);
if (!siteID) {
@@ -130,7 +146,11 @@ export const hourlyCommentsMetricsHandler = ({
export const dailyUsersMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
}: AppOptions): RequestHandler<TenantCoralRequest> => async (
req,
res,
next
) => {
try {
const { tenantID, tz, now } = getMetricsOptions(req);
@@ -146,7 +166,11 @@ export const dailyUsersMetricsHandler = ({
export const todayStoriesMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
}: AppOptions): RequestHandler<TenantCoralRequest> => async (
req,
res,
next
) => {
try {
const { tenantID, siteID, tz, now } = getMetricsOptions(req);
if (!siteID) {
@@ -158,6 +182,7 @@ export const todayStoriesMetricsHandler = ({
tenantID,
siteID,
tz,
20,
now
);
+8 -16
View File
@@ -4,7 +4,11 @@ import { CLIENT_ID_HEADER } from "coral-common/constants";
import { AppOptions } from "coral-server/app";
import { graphqlMiddleware } from "coral-server/app/middleware/graphql";
import GraphContext, { GraphContextOptions } from "coral-server/graph/context";
import { Request, RequestHandler } from "coral-server/types/express";
import {
Request,
RequestHandler,
TenantCoralRequest,
} from "coral-server/types/express";
export type GraphMiddlewareOptions = Pick<
AppOptions,
@@ -30,27 +34,15 @@ export const graphQLHandler = ({
config,
metrics,
...options
}: GraphMiddlewareOptions): RequestHandler => {
}: GraphMiddlewareOptions): RequestHandler<TenantCoralRequest> => {
// Generate the schema hash.
const schemaHash = generateSchemaHash(schema);
return graphqlMiddleware(
config,
async (req: Request) => {
if (!req.coral) {
throw new Error("coral was not set");
}
async (req: Request<TenantCoralRequest>) => {
// Pull out some useful properties from Coral.
const { id, now, tenant, cache, logger, persisted } = req.coral;
if (!cache) {
throw new Error("cache was not set");
}
if (!tenant) {
throw new Error("tenant was not set");
}
const { id, now, tenant, logger, persisted } = req.coral;
// Create some new options to store the tenant context details inside.
const opts: GraphContextOptions = {
+12 -13
View File
@@ -25,7 +25,11 @@ import {
isInstalled,
} from "coral-server/services/tenant";
import { create, CreateUser } from "coral-server/services/users";
import { Request, RequestHandler } from "coral-server/types/express";
import {
CoralRequest,
Request,
RequestHandler,
} from "coral-server/types/express";
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
@@ -37,7 +41,7 @@ export type TenantInstallCheckHandlerOptions = Pick<
export const installCheckHandler = ({
config,
redis,
}: TenantInstallCheckHandlerOptions): RequestHandler => {
}: TenantInstallCheckHandlerOptions): RequestHandler<CoralRequest> => {
const { managementEnabled, signingConfig } = managementSigningConfig(config);
const limiter = new RequestLimiter({
redis,
@@ -52,21 +56,16 @@ export const installCheckHandler = ({
// Limit based on the IP address.
await limiter.test(req, req.ip);
if (!req.coral) {
return next(new Error("coral was not set"));
}
// Pull the tenant out.
const { tenant, cache } = req.coral;
if (!req.coral.cache) {
return next(new Error("cache was not set"));
}
if (req.coral.tenant) {
// There's already a Tenant on the request! No need to process further.
// If there's already a Tenant on the request! No need to process further.
if (tenant) {
return next(new TenantInstalledAlreadyError());
}
// Check to see if the server already has a tenant installed.
const alreadyInstalled = await isInstalled(req.coral.cache.tenant);
const alreadyInstalled = await isInstalled(cache.tenant);
if (!alreadyInstalled) {
// No tenants are installed at all, we can of course proceed with the
// install now.
@@ -274,7 +273,7 @@ async function checkForInstallationToken(
const { token } = await verifyInstallationTokenString(
signingConfig,
accessToken,
req.coral!.now
req.coral.now
);
// Check to see that the domain on the token matches the hostname on
@@ -5,7 +5,7 @@ import { validate } from "coral-server/app/request/body";
import { supportsMediaType } from "coral-server/models/tenant";
import { translate } from "coral-server/services/i18n";
import { fetchOEmbedResponse } from "coral-server/services/oembed";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
const OEmbedQuerySchema = Joi.object().keys({
url: Joi.string().uri().required(),
@@ -21,12 +21,12 @@ interface OEmbedQuery {
export type OembedHandler = Pick<AppOptions, "i18n">;
export const oembedHandler = ({ i18n }: OembedHandler): RequestHandler => {
export const oembedHandler = ({
i18n,
}: OembedHandler): RequestHandler<TenantCoralRequest> => {
// TODO: add some kind of rate limiting or spam protection
return async (req, res, next) => {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant } = req.coral;
try {
const { type, url, maxWidth }: OEmbedQuery = validate(
@@ -1,22 +1,17 @@
import { searchGiphy } from "coral-server/services/giphy";
import { Request, RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
export const gifSearchHandler: RequestHandler = async (
req: Request,
export const gifSearchHandler: RequestHandler<TenantCoralRequest> = async (
req,
res,
next
) => {
if (!req.coral) {
return next(new Error("coral was not set"));
}
const { tenant } = req.coral;
if (!req.query.query) {
return next(new Error("search query required"));
}
const coral = req.coral;
const tenant = coral.tenant!;
try {
const results = await searchGiphy(
req.query.query,
@@ -0,0 +1,102 @@
import Joi from "@hapi/joi";
import { DateTime } from "luxon";
import { AppOptions } from "coral-server/app";
import { validate } from "coral-server/app/request/body";
import { calculateTotalPublishedCommentCount } from "coral-server/models/comment";
import { retrieveTopStoryMetrics } from "coral-server/models/comment/metrics";
import { retrieveSite } from "coral-server/models/site";
import { retrieveManyStories, Story } from "coral-server/models/story";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
export type Options = Pick<AppOptions, "mongo">;
const ActiveStoriesQuerySchema = Joi.object().keys({
callback: Joi.string().allow("").optional(),
siteID: Joi.string().required(),
});
interface ActiveStoriesQuery {
callback: string;
siteID: string;
}
/**
* ActiveHandlerResponse is part of the Active Stories API. Changes to this
* interface should be completed only after a deprecation cycle.
*/
interface ActiveHandlerResponse {
stories: Array<{
id: string;
url: string;
title: string | null;
image: string | null;
publishedAt: Date | null;
count: number;
}>;
}
export const activeHandler = ({
mongo,
}: Options): RequestHandler<TenantCoralRequest> => async (req, res, next) => {
try {
// Grab the Tenant.
const { tenant, now } = req.coral;
// Ensure we have a siteID on the query.
const { siteID }: ActiveStoriesQuery = validate(
ActiveStoriesQuerySchema,
req.query
);
// Check to see that this site does exist for this Tenant.
const site = await retrieveSite(mongo, tenant.id, siteID);
if (!site) {
throw new Error("site not found");
}
// Find top active stories in the last 24 hours.
const start = DateTime.fromJSDate(now).minus({ hours: 24 }).toJSDate();
const results = await retrieveTopStoryMetrics(
mongo,
tenant.id,
siteID,
5,
start,
now
);
// Fetch all the stories for each count. This will be returned in the same
// ordering of the counts.
const stories = await retrieveManyStories(
mongo,
tenant.id,
results.map(({ _id }) => _id)
);
// Ensure that all entries are not null.
if (!stories.every((story) => story) || results.length !== stories.length) {
throw new Error("some stories with comments were not found");
}
// Generate the response using the existing order of the stories.
const response: ActiveHandlerResponse = {
// We verified above that there was no null stories in the array.
stories: (stories as Story[]).map(
({ id, url, metadata, commentCounts }) => ({
id,
url,
title: metadata?.title || null,
image: metadata?.image || null,
publishedAt: metadata?.publishedAt || null,
count: calculateTotalPublishedCommentCount(commentCounts.status),
})
),
};
// Respond using jsonp.
return res.jsonp(response);
} catch (err) {
return next(err);
}
};
@@ -1,29 +1,58 @@
import Joi from "@hapi/joi";
import { AppOptions } from "coral-server/app";
import { validate } from "coral-server/app/request/body";
import { calculateTotalPublishedCommentCount } from "coral-server/models/comment";
import { translate } from "coral-server/services/i18n";
import { find } from "coral-server/services/stories";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
const NUMBER_CLASS_NAME = "coral-count-number";
const TEXT_CLASS_NAME = "coral-count-text";
export type CountOptions = Pick<AppOptions, "mongo" | "tenantCache" | "i18n">;
const StoryCountQuerySchema = Joi.object().keys({
// Required for JSONP support.
callback: Joi.string().allow("").optional(),
id: Joi.string().optional(),
url: Joi.string().optional(),
notext: Joi.string().allow("true", "false").required(),
ref: Joi.string().required(),
});
interface StoryCountQuery {
callback: string;
id?: string;
url?: string;
notext: "true" | "false";
ref: string;
}
/**
* countHandler returns translated comment counts using JSONP.
*/
export const countHandler = ({
mongo,
i18n,
}: CountOptions): RequestHandler => async (req, res, next) => {
}: CountOptions): RequestHandler<TenantCoralRequest> => async (
req,
res,
next
) => {
try {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant } = req.coral;
// Ensure we have something to query with.
const { id, url, notext, ref }: StoryCountQuery = validate(
StoryCountQuerySchema,
req.query
);
// Try to query the story.
const story = await find(mongo, tenant, {
id: req.query.id,
url: req.query.url,
id,
url,
});
const count = story
@@ -31,7 +60,7 @@ export const countHandler = ({
: 0;
let html = "";
if (req.query.notext === "true") {
if (notext === "true") {
// We only need the count without the text.
html = `<span class="${NUMBER_CLASS_NAME}">${count}</span>`;
} else {
@@ -52,7 +81,7 @@ export const countHandler = ({
// Respond using jsonp.
res.jsonp({
// Reference from the client that we'll just send back as it is.
ref: req.query.ref,
ref,
html,
});
} catch (err) {
@@ -1 +1,2 @@
export * from "./active";
export * from "./count";
@@ -4,7 +4,7 @@ import {
redeemDownloadToken,
sendUserDownload,
} from "coral-server/services/users/download";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
type AdminDownloadOptions = Pick<
AppOptions,
@@ -15,11 +15,9 @@ export const userDownloadHandler = ({
mongo,
redis,
signingConfig,
}: AdminDownloadOptions): RequestHandler => {
}: AdminDownloadOptions): RequestHandler<TenantCoralRequest> => {
return async (req, res, next) => {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
const { token } = req.query;
const { sub: userID } = decodeJWT(token);
@@ -37,7 +35,7 @@ export const userDownloadHandler = ({
tenant,
signingConfig,
token,
coral.now
now
);
// Only load comments since this download token was issued.
+2 -6
View File
@@ -20,16 +20,12 @@ async function retrieveSiteFromEmbed(
mongo: Db,
req: Request
): Promise<Site | null> {
if (!req.coral || !req.coral.tenant) {
const { tenant } = req.coral;
if (!tenant) {
// There is no tenant for the request, don't add any headers.
return null;
}
// Pull the tenant and the logger from the request.
const {
coral: { tenant },
} = req;
// Attempt to detect the site based on the query parameters.
const {
storyURL = "",
@@ -2,7 +2,7 @@ import { AppOptions } from "coral-server/app";
import { RawQueryNotAuthorized } from "coral-server/errors";
import { getPersistedQuery } from "coral-server/graph/persisted";
import { hasFeatureFlag } from "coral-server/models/tenant";
import { RequestHandler } from "coral-server/types/express";
import { RequestHandler, TenantCoralRequest } from "coral-server/types/express";
import {
GQLFEATURE_FLAG,
@@ -17,16 +17,10 @@ type PersistedQueryMiddlewareOptions = Pick<
const persistedQueryMiddleware = ({
persistedQueriesRequired,
persistedQueryCache,
}: PersistedQueryMiddlewareOptions): RequestHandler => async (
req,
res,
next
) => {
}: PersistedQueryMiddlewareOptions): RequestHandler<
TenantCoralRequest
> => async (req, res, next) => {
try {
if (!req.coral || !req.coral.tenant) {
throw new Error("tenant was not set");
}
// Handle the payload if it is a persisted query.
const body = req.method === "GET" ? req.query : req.body;
const persisted = await getPersistedQuery(persistedQueryCache, body);
+3 -11
View File
@@ -19,19 +19,11 @@ export const installedMiddleware = ({
res,
next
) => {
if (!req.coral) {
return next(new Error("coral was not set"));
}
if (!req.coral.cache) {
return next(new Error("cache was not set"));
}
const installed = await isInstalled(req.coral.cache.tenant, req.hostname);
// If Coral is installed, and redirectIfInstall is true, then it will redirect.
// If Coral is not installed, and redirectIfInstall is false, then it will also
// redirect.
// If Coral is installed, and redirectIfInstall is true, then it will
// redirect. If Coral is not installed, and redirectIfInstall is false, then
// it will also redirect.
if (installed === redirectIfInstalled) {
return res.redirect(redirectURL);
}
@@ -1,5 +1,5 @@
import Joi from "@hapi/joi";
import { NextFunction, RequestHandler, Response } from "express";
import { NextFunction, Response } from "express";
import { Redis } from "ioredis";
import jwt from "jsonwebtoken";
import passport, { Authenticator } from "passport";
@@ -20,7 +20,11 @@ import {
revokeJWT,
signTokenString,
} from "coral-server/services/jwt";
import { Request } from "coral-server/types/express";
import {
Request,
RequestHandler,
TenantCoralRequest,
} from "coral-server/types/express";
export type VerifyCallback = (
err?: Error | null,
@@ -67,7 +71,11 @@ const LogoutTokenSchema = Joi.object().keys({
exp: Joi.number().optional(),
});
export async function handleLogout(redis: Redis, req: Request, res: Response) {
export async function handleLogout(
redis: Redis,
req: Request<TenantCoralRequest>,
res: Response
) {
// Extract the token from the request.
const token = extractTokenFromRequest(req);
if (!token) {
@@ -75,8 +83,7 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
return res.sendStatus(204);
}
// Coral is guaranteed at this point.
const { now } = req.coral!;
const { now } = req.coral;
// Decode the token.
const decoded = jwt.decode(token, {});
@@ -103,25 +110,15 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
export async function handleSuccessfulLogin(
user: User,
signingConfig: JWTSigningConfig,
req: Request,
req: Request<TenantCoralRequest>,
res: Response,
next: NextFunction
) {
try {
// Coral is guaranteed at this point.
const coral = req.coral!;
// Tenant is guaranteed at this point.
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Grab the token.
const token = await signTokenString(
signingConfig,
user,
tenant,
{},
coral.now
);
const token = await signTokenString(signingConfig, user, tenant, {}, now);
// Set the cache control headers.
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
@@ -175,7 +172,7 @@ export async function handleOAuth2Callback(
err: Error | null,
user: User | null,
signingConfig: JWTSigningConfig,
req: Request,
req: Request<TenantCoralRequest>,
res: Response
) {
const path = "/embed/auth/callback";
@@ -189,9 +186,7 @@ export async function handleOAuth2Callback(
}
try {
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Grab the token.
const accessToken = await signTokenString(
@@ -199,7 +194,7 @@ export async function handleOAuth2Callback(
user,
tenant,
{},
coral.now
now
);
// NOTE: disabled cookie support due to ITP/First Party Cookie bugs
@@ -235,7 +230,7 @@ export const wrapOAuth2Authn = (
signingConfig: JWTSigningConfig,
name: string,
options?: any
): RequestHandler => (req: Request, res, next) =>
): RequestHandler<TenantCoralRequest> => (req, res, next) =>
authenticator.authenticate(
name,
{ ...options, session: false },
@@ -262,7 +257,7 @@ export const wrapAuthn = (
signingConfig: JWTSigningConfig,
name: string,
options?: any
): RequestHandler => (req: Request, res, next) =>
): RequestHandler<TenantCoralRequest> => (req, res, next) =>
authenticator.authenticate(
name,
{ ...options, session: false },
@@ -291,7 +286,7 @@ export const wrapAuthn = (
*/
export const authenticate = (
authenticator: passport.Authenticator
): RequestHandler => (req, res, next) =>
): RequestHandler<TenantCoralRequest> => (req, res, next) =>
authenticator.authenticate(
"jwt",
{ session: false },
@@ -13,7 +13,7 @@ import {
extractTokenFromRequest,
StandardHeader,
} from "coral-server/services/jwt";
import { Request } from "coral-server/types/express";
import { Request, TenantCoralRequest } from "coral-server/types/express";
import { JWTToken, JWTVerifier } from "./verifiers/jwt";
import { OIDCIDToken, OIDCVerifier } from "./verifiers/oidc";
@@ -123,7 +123,7 @@ export class JWTStrategy extends Strategy {
this.verifiers = createVerifiers(options);
}
public async authenticate(req: Request) {
public async authenticate(req: Request<TenantCoralRequest>) {
// Get the token from the request.
const token = extractTokenFromRequest(req);
if (!token) {
@@ -132,7 +132,7 @@ export class JWTStrategy extends Strategy {
return this.pass();
}
const { now, tenant } = req.coral!;
const { now, tenant } = req.coral;
if (!tenant) {
return this.error(new TenantNotFoundError(req.hostname));
}
@@ -11,14 +11,14 @@ import {
retrieveUserWithProfile,
verifyUserPassword,
} from "coral-server/models/user";
import { Request } from "coral-server/types/express";
import { Request, TenantCoralRequest } from "coral-server/types/express";
const verifyFactory = (
mongo: Db,
ipLimiter: RequestLimiter,
emailLimiter: RequestLimiter
) => async (
req: Request,
req: Request<TenantCoralRequest>,
emailInput: string,
passwordInput: string,
done: VerifyCallback
@@ -34,8 +34,7 @@ const verifyFactory = (
await ipLimiter.test(req, req.ip);
await emailLimiter.test(req, email);
// The tenant is guaranteed at this point.
const tenant = req.coral!.tenant!;
const { tenant } = req.coral;
// Get the user from the database.
const user = await retrieveUserWithProfile(mongo, tenant.id, {
@@ -12,7 +12,7 @@ import {
TenantCache,
TenantCacheAdapter,
} from "coral-server/services/tenant/cache";
import { Request } from "coral-server/types/express";
import { Request, TenantCoralRequest } from "coral-server/types/express";
interface OAuth2Integration {
enabled: boolean;
@@ -66,16 +66,14 @@ export default abstract class OAuth2Strategy<
): Promise<User | null | undefined>;
protected verifyCallback = async (
req: Request,
req: Request<TenantCoralRequest>,
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback
) => {
try {
// Coral is defined at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
const { tenant, now } = req.coral;
// Get the integration.
const integration = this.getIntegration(tenant.auth.integrations);
@@ -85,7 +83,7 @@ export default abstract class OAuth2Strategy<
tenant,
integration as Required<T>,
profile,
coral.now
now
);
if (!user) {
return done(null);
@@ -97,10 +95,9 @@ export default abstract class OAuth2Strategy<
}
};
public authenticate(req: Request) {
public authenticate(req: Request<TenantCoralRequest>) {
try {
// Coral is defined at this point.
const tenant = req.coral!.tenant!;
const { tenant } = req.coral;
// Get the integration.
const integration = this.getIntegration(tenant.auth.integrations);
@@ -29,7 +29,7 @@ import {
} from "coral-server/services/tenant/cache";
import { findOrCreate } from "coral-server/services/users";
import { validateUsername } from "coral-server/services/users/helpers";
import { Request } from "coral-server/types/express";
import { Request, TenantCoralRequest } from "coral-server/types/express";
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
@@ -355,9 +355,8 @@ export default class OIDCStrategy extends Strategy {
return done(new Error("no id_token in params"));
}
// Grab the tenant out of the request, as we need some more details. Coral
// is guaranteed at this point.
const { now, tenant } = req.coral!;
// Grab the tenant out of the request, as we need some more details.
const { now, tenant } = req.coral;
if (!tenant) {
// TODO: return a better error.
return done(new Error("tenant not found"));
@@ -416,12 +415,8 @@ export default class OIDCStrategy extends Strategy {
);
}
private lookupStrategy(req: Request): OAuth2Strategy {
const { tenant } = req.coral!;
if (!tenant) {
// TODO: return a better error.
throw new Error("tenant not found");
}
private lookupStrategy(req: Request<TenantCoralRequest>): OAuth2Strategy {
const { tenant } = req.coral;
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
@@ -445,7 +440,7 @@ export default class OIDCStrategy extends Strategy {
return tenantIntegration.strategy;
}
public authenticate(req: Request) {
public authenticate(req: Request<TenantCoralRequest>) {
try {
// Lookup the strategy.
const strategy = this.lookupStrategy(req);
+4 -8
View File
@@ -29,18 +29,14 @@ export const tenantMiddleware = ({
req.coral = {
id,
now,
cache: {
// Attach the tenant cache to the request.
tenant: cache,
},
logger: logger.child({ context: "http", contextID: id }, true),
};
}
// Set the Coral Tenant Cache on the request.
if (!req.coral.cache) {
req.coral.cache = {
// Attach the tenant cache to the request.
tenant: cache,
};
}
// Attach the tenant to the request.
const tenant = await cache.retrieveByDomain(req.hostname);
if (!tenant) {
@@ -22,8 +22,12 @@ export const userLimiterMiddleware = ({
});
return async (req, res, next) => {
if (!req.user) {
return next();
}
limiter
.test(req, req.user!.id)
.test(req, req.user.id)
.then(() => next())
.catch((err) => next(err));
};
+5 -11
View File
@@ -15,17 +15,11 @@ export function createNewInstallRouter(app: AppOptions): Router {
// Create a router.
const router = createAPIRouter();
router.get(
"/",
tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }),
installCheckHandler(app)
);
router.post(
"/",
jsonMiddleware(REQUEST_MAX),
tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }),
installHandler(app)
);
// Allow the tenant to be passed on installations.
router.use(tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }));
router.get("/", installCheckHandler(app));
router.post("/", jsonMiddleware(REQUEST_MAX), installHandler(app));
return router;
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { AppOptions } from "coral-server/app";
import { countHandler } from "coral-server/app/handlers";
import { activeHandler, countHandler } from "coral-server/app/handlers";
import { createAPIRouter } from "./helpers";
@@ -8,6 +8,7 @@ export function createStoryRouter(app: AppOptions) {
const router = createAPIRouter({ cache: "2m" });
router.get("/count.js", countHandler(app));
router.get("/active.js", activeHandler(app));
return router;
}
+2 -1
View File
@@ -115,7 +115,7 @@ const clientHandler = ({
// Grab the locale code from the tenant configuration, if available.
let locale: LanguageCode = defaultLocale;
if (req.coral && req.coral.tenant) {
if (req.coral.tenant) {
locale = req.coral.tenant.locale;
}
@@ -155,6 +155,7 @@ export function mountClientRoutes(
);
return;
}
// Tenant identification middleware.
router.use(
tenantMiddleware({
+33 -9
View File
@@ -15,6 +15,8 @@ import {
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
import { PUBLISHED_STATUSES } from "./constants";
export async function retrieveHourlyCommentMetrics(
mongo: Db,
tenantID: string,
@@ -145,20 +147,26 @@ export async function retrieveAverageCommentsMetric(
return Math.floor(total / hours);
}
export async function retrieveTodayTopStoryMetrics(
export async function retrieveTopStoryMetrics(
mongo: Db,
tenantID: string,
siteID: string,
timezone: string,
limit: number,
start: Date,
now: Date
) {
const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day");
const end = DateTime.fromJSDate(now);
// Return the last 24 hours worth of comments.
const results = await collection<Result>(mongo)
.aggregate([
{ $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } },
{
$match: {
tenantID,
siteID,
createdAt: { $gte: start, $lte: now },
status: {
$in: PUBLISHED_STATUSES,
},
},
},
{
$group: {
_id: "$storyID",
@@ -166,10 +174,26 @@ export async function retrieveTodayTopStoryMetrics(
},
},
{ $sort: { count: -1 } },
// TODO: 17 was for visual treatment, feel free to change this!
{ $limit: 17 },
{ $limit: limit },
])
.toArray();
return results;
}
export async function retrieveTodayTopStoryMetrics(
mongo: Db,
tenantID: string,
siteID: string,
timezone: string,
limit: number,
now: Date
) {
// Return the last day worth of comments.
const start = DateTime.fromJSDate(now)
.setZone(timezone)
.startOf("day")
.toJSDate();
return retrieveTopStoryMetrics(mongo, tenantID, siteID, limit, start, now);
}
+9 -6
View File
@@ -1,5 +1,6 @@
import { NextFunction, Request as ExpressRequest, Response } from "express";
import { RequireProperty } from "coral-common/types";
import { Logger } from "coral-server/logger";
import { PersistedQuery } from "coral-server/models/queries";
import { Tenant } from "coral-server/models/tenant";
@@ -9,7 +10,7 @@ import { TenantCache } from "coral-server/services/tenant/cache";
export interface CoralRequest {
id: string;
now: Date;
cache?: {
cache: {
tenant: TenantCache;
};
tenant?: Tenant;
@@ -17,20 +18,22 @@ export interface CoralRequest {
logger: Logger;
}
export interface Request extends ExpressRequest {
coral?: CoralRequest;
export type TenantCoralRequest = RequireProperty<CoralRequest, "tenant">;
export interface Request<T = CoralRequest> extends ExpressRequest {
coral: T;
user?: User;
}
export type RequestHandler = (
req: Request,
export type RequestHandler<T = CoralRequest> = (
req: Request<T>,
res: Response,
next: NextFunction
) => void;
export type ErrorRequestHandler = (
err: Error,
req: Request,
req: Request<CoralRequest>,
res: Response,
next: NextFunction
) => void;
+8 -4
View File
@@ -1,11 +1,7 @@
import dotenv from "dotenv";
import { rewrite } from "env-rewrite";
import express from "express";
import sourceMapSupport from "source-map-support";
import createCoral from "./core";
import logger from "./core/server/logger";
// Configure the source map support so stack traces will reference the source
// files rather than the transpiled code.
sourceMapSupport.install({
@@ -25,6 +21,14 @@ rewrite();
// the environment.
dotenv.config();
// NOTE: It is required for the `dotenv` module to be configured before other
// modules to ensure the rewriting takes place before those modules load!
import express from "express";
import createCoral from "./core";
import logger from "./core/server/logger";
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.