diff --git a/src/core/client/framework/lib/relay/localState.ts b/src/core/client/framework/lib/relay/localState.ts index f97578510..880e89609 100644 --- a/src/core/client/framework/lib/relay/localState.ts +++ b/src/core/client/framework/lib/relay/localState.ts @@ -26,7 +26,6 @@ export function setAccessTokenInLocalState( localRecord.setValue(accessToken || "", "accessToken"); if (accessToken) { const { payload } = parseJWT(accessToken); - // TODO: (cvle) maybe a timer to detect when accessToken has expired? // Set the exp if it's valid. diff --git a/src/core/client/install/App/App.tsx b/src/core/client/install/App/App.tsx index de8302b3b..250e279ab 100644 --- a/src/core/client/install/App/App.tsx +++ b/src/core/client/install/App/App.tsx @@ -1,21 +1,79 @@ -import React, { Component } from "react"; +import React, { FunctionComponent, useEffect, useState } from "react"; +import { ERROR_CODES } from "coral-common/errors"; +import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { useFetch } from "coral-framework/lib/relay"; +import { CallOut, Flex, Typography } from "coral-ui/components"; + +import CheckInstallFetch from "./CheckInstallFetch"; import InstallWizard from "./InstallWizard"; import MainBar from "./MainBar"; +import Wizard from "./Wizard"; import styles from "./App.css"; -class App extends Component { - public render() { - return ( -
- -
- -
+type State = "loading" | "success" | "failure"; + +const App: FunctionComponent = () => { + const [error, setError] = useState(null); + const [state, setState] = useState("loading"); + const checkInstall = useFetch(CheckInstallFetch); + const context = useCoralContext(); + useEffect(() => { + async function check() { + try { + await checkInstall({}); + setState("success"); + } catch (err) { + setError(err.message); + setState("failure"); + if (err.code !== ERROR_CODES.RATE_LIMIT_EXCEEDED) { + await context.clearSession(""); + } + } + } + + check(); + }, []); + + return ( +
+ +
+
- ); - } +
+ ); +}; + +interface AppStateProps { + state: State; + error: string | null; } +const AppState: FunctionComponent = ({ state, error }) => { + switch (state) { + case "loading": + return null; + case "success": + return ; + default: + return ; + } +}; + +interface FailedAppStateProps { + error: string | null; +} + +const FailedAppState: FunctionComponent = ({ error }) => ( + + + + {error} + + + +); + export default App; diff --git a/src/core/client/install/App/CheckInstallFetch.ts b/src/core/client/install/App/CheckInstallFetch.ts new file mode 100644 index 000000000..07b09fc49 --- /dev/null +++ b/src/core/client/install/App/CheckInstallFetch.ts @@ -0,0 +1,13 @@ +import { Environment } from "relay-runtime"; + +import { createFetch } from "coral-framework/lib/relay"; + +const CheckInstallFetch = createFetch( + "checkInstallFetch", + async (environment: Environment, variables: any, { rest }) => + await rest.fetch("/install", { + method: "GET", + }) +); + +export default CheckInstallFetch; diff --git a/src/core/client/install/App/__snapshots__/App.spec.tsx.snap b/src/core/client/install/App/__snapshots__/App.spec.tsx.snap index e67a46d01..226293a37 100644 --- a/src/core/client/install/App/__snapshots__/App.spec.tsx.snap +++ b/src/core/client/install/App/__snapshots__/App.spec.tsx.snap @@ -8,7 +8,10 @@ exports[`renders correctly 1`] = `
- +
`; diff --git a/src/core/client/install/constants.ts b/src/core/client/install/constants.ts new file mode 100644 index 000000000..a051132e2 --- /dev/null +++ b/src/core/client/install/constants.ts @@ -0,0 +1 @@ +export const INSTALL_ACCESS_TOKEN_KEY = "coral:install:accessToken"; diff --git a/src/core/client/install/index.tsx b/src/core/client/install/index.tsx index 76b8c0a76..8dc1d63c5 100644 --- a/src/core/client/install/index.tsx +++ b/src/core/client/install/index.tsx @@ -4,6 +4,7 @@ import ReactDOM from "react-dom"; import { createManaged } from "coral-framework/lib/bootstrap"; import App from "./App"; +import { initLocalState } from "./local"; import localesData from "./locales"; // Import css variables. @@ -12,6 +13,7 @@ import "coral-ui/theme/variables.css"; async function main() { const ManagedCoralContextProvider = await createManaged({ localesData, + initLocalState, }); const Index: FunctionComponent = () => ( diff --git a/src/core/client/install/local/index.ts b/src/core/client/install/local/index.ts new file mode 100644 index 000000000..2b196f8c4 --- /dev/null +++ b/src/core/client/install/local/index.ts @@ -0,0 +1 @@ +export { default as initLocalState } from "./initLocalState"; diff --git a/src/core/client/install/local/initLocalState.ts b/src/core/client/install/local/initLocalState.ts new file mode 100644 index 000000000..7c33a89c9 --- /dev/null +++ b/src/core/client/install/local/initLocalState.ts @@ -0,0 +1,33 @@ +import { Environment } from "relay-runtime"; + +import { clearHash, getParamsFromHash } from "coral-framework/helpers"; +import { CoralContext } from "coral-framework/lib/bootstrap"; +import { initLocalBaseState } from "coral-framework/lib/relay"; + +import { INSTALL_ACCESS_TOKEN_KEY } from "../constants"; + +/** + * Initializes the local state, before we start the App. + */ +export default async function initLocalState( + environment: Environment, + context: CoralContext +) { + // Get the access token from the session storage. + let accessToken = await context.sessionStorage.getItem( + INSTALL_ACCESS_TOKEN_KEY + ); + + // Get all the parameters from the hash. + const params = getParamsFromHash(); + if (params && params.accessToken) { + // As there's an access token in the hash, let's clear it. + clearHash(); + + // Save the token in session storage to override what we found. + accessToken = params.accessToken; + await context.sessionStorage.setItem(INSTALL_ACCESS_TOKEN_KEY, accessToken); + } + + await initLocalBaseState(environment, context, accessToken); +} diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabQuery.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabQuery.tsx index 1841910fa..b5d9dd554 100644 --- a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabQuery.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabQuery.tsx @@ -1,3 +1,4 @@ +import { Localized } from "fluent-react/compat"; import React, { FunctionComponent } from "react"; import { ReadyState } from "react-relay"; @@ -25,12 +26,20 @@ export const render = (data: ReadyState) => { return
{data.error.message}
; } if (data.props) { + if (!data.props.story) { + return ( + +
Story not found
+
+ ); + } + return ( ); @@ -57,7 +66,7 @@ const AllCommentsTabQuery: FunctionComponent = props => { viewer { ...AllCommentsTabContainer_viewer } - story(id: $storyID, url: $storyURL) { + story: stream(id: $storyID, url: $storyURL) { ...AllCommentsTabContainer_story @arguments(orderBy: $commentsOrderBy) } diff --git a/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentsQuery.tsx b/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentsQuery.tsx index 48d04c5a9..56fbca5f9 100644 --- a/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentsQuery.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentsQuery.tsx @@ -1,3 +1,4 @@ +import { Localized } from "fluent-react/compat"; import React, { FunctionComponent } from "react"; import { ReadyState } from "react-relay"; @@ -23,6 +24,7 @@ export const render = (data: ReadyState) => { if (data.error) { return
{data.error.message}
; } + if (!data.props) { return ( @@ -30,12 +32,21 @@ export const render = (data: ReadyState) => { ); } + if (data.props) { + if (!data.props.story) { + return ( + +
Story not found
+
+ ); + } + return ( ); } @@ -64,7 +75,7 @@ const FeaturedCommentsQuery: FunctionComponent = props => { viewer { ...FeaturedCommentsContainer_viewer } - story(id: $storyID, url: $storyURL) { + story: stream(id: $storyID, url: $storyURL) { ...FeaturedCommentsContainer_story @arguments(orderBy: $commentsOrderBy) } diff --git a/src/core/client/stream/tabs/Comments/Stream/StreamQuery.tsx b/src/core/client/stream/tabs/Comments/Stream/StreamQuery.tsx index a7294f731..8103843d7 100644 --- a/src/core/client/stream/tabs/Comments/Stream/StreamQuery.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/StreamQuery.tsx @@ -75,7 +75,7 @@ const StreamQuery: FunctionComponent = props => { viewer { ...StreamContainer_viewer } - story(id: $storyID, url: $storyURL) { + story: stream(id: $storyID, url: $storyURL) { ...StreamContainer_story } settings { diff --git a/src/core/client/stream/tabs/Profile/ProfileQuery.tsx b/src/core/client/stream/tabs/Profile/ProfileQuery.tsx index 5c125b642..25c4b009b 100644 --- a/src/core/client/stream/tabs/Profile/ProfileQuery.tsx +++ b/src/core/client/stream/tabs/Profile/ProfileQuery.tsx @@ -81,7 +81,7 @@ const ProfileQuery: FunctionComponent = ({ query={graphql` query ProfileQuery($storyID: ID, $storyURL: String) { - story(id: $storyID, url: $storyURL) { + story: stream(id: $storyID, url: $storyURL) { ...ProfileContainer_story } viewer { diff --git a/src/core/client/stream/test/comments/featured/loadMore.spec.tsx b/src/core/client/stream/test/comments/featured/loadMore.spec.tsx index 71833a379..5a9d2e07b 100644 --- a/src/core/client/stream/test/comments/featured/loadMore.spec.tsx +++ b/src/core/client/stream/test/comments/featured/loadMore.spec.tsx @@ -18,38 +18,39 @@ const story = storyWithFeaturedComments; async function createTestRenderer( params: CreateTestRendererParams = {} ) { + const storyResolver = () => ({ + ...story, + featuredComments: createQueryResolverStub( + ({ variables }) => { + if (!variables.after) { + return { + edges: [story.comments.edges[0]], + pageInfo: { + endCursor: story.comments.edges[0].cursor, + hasNextPage: true, + }, + }; + } + expectAndFail(variables.after).toBe(story.comments.edges[0].cursor); + return { + edges: [story.comments.edges[1]], + pageInfo: { + endCursor: story.comments.edges[1].cursor, + hasNextPage: false, + }, + }; + } + ) as any, + }); + const { testRenderer, context } = create({ ...params, resolvers: pureMerge( createResolversStub({ Query: { settings: () => settings, - story: () => ({ - ...story, - featuredComments: createQueryResolverStub( - ({ variables }) => { - if (!variables.after) { - return { - edges: [story.comments.edges[0]], - pageInfo: { - endCursor: story.comments.edges[0].cursor, - hasNextPage: true, - }, - }; - } - expectAndFail(variables.after).toBe( - story.comments.edges[0].cursor - ); - return { - edges: [story.comments.edges[1]], - pageInfo: { - endCursor: story.comments.edges[1].cursor, - hasNextPage: false, - }, - }; - } - ) as any, - }), + story: storyResolver, + stream: storyResolver, }, }), params.resolvers diff --git a/src/core/client/stream/test/comments/featured/renderFeaturedStream.spec.tsx b/src/core/client/stream/test/comments/featured/renderFeaturedStream.spec.tsx index 927a97224..f49cad7f6 100644 --- a/src/core/client/stream/test/comments/featured/renderFeaturedStream.spec.tsx +++ b/src/core/client/stream/test/comments/featured/renderFeaturedStream.spec.tsx @@ -22,7 +22,7 @@ async function createTestRenderer( createResolversStub({ Query: { settings: () => settings, - story: () => ({ + stream: () => ({ ...story, featuredComments: createQueryResolverStub( () => { diff --git a/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx b/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx index f1299e239..47d307f95 100644 --- a/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx +++ b/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx @@ -66,6 +66,13 @@ beforeEach(() => { .withArgs(undefined, { id: storyStub.id, url: null }) .returns(storyStub) ), + stream: createSinonStub( + s => s.throws(), + s => + s + .withArgs(undefined, { id: storyStub.id, url: null }) + .returns(storyStub) + ), settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/comments/permalink/permalinkViewCommentNotFound.spec.tsx b/src/core/client/stream/test/comments/permalink/permalinkViewCommentNotFound.spec.tsx index def40d754..cffe0ee59 100644 --- a/src/core/client/stream/test/comments/permalink/permalinkViewCommentNotFound.spec.tsx +++ b/src/core/client/stream/test/comments/permalink/permalinkViewCommentNotFound.spec.tsx @@ -42,6 +42,13 @@ beforeEach(() => { .withArgs(undefined, { id: storyStub.id, url: null }) .returns(storyStub) ), + stream: createSinonStub( + s => s.throws(), + s => + s + .withArgs(undefined, { id: storyStub.id, url: null }) + .returns(storyStub) + ), settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/comments/stream/banned.spec.tsx b/src/core/client/stream/test/comments/stream/banned.spec.tsx index d3f26f0fb..6ac76ccdd 100644 --- a/src/core/client/stream/test/comments/stream/banned.spec.tsx +++ b/src/core/client/stream/test/comments/stream/banned.spec.tsx @@ -35,7 +35,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => bannedUser, - story: () => + stream: () => pureMerge(story, { comments: { edges: [ diff --git a/src/core/client/stream/test/comments/stream/charCountEditComment.spec.tsx b/src/core/client/stream/test/comments/stream/charCountEditComment.spec.tsx index b615c7e98..7ced37b1f 100644 --- a/src/core/client/stream/test/comments/stream/charCountEditComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/charCountEditComment.spec.tsx @@ -40,7 +40,7 @@ async function createTestRenderer( Query: { settings: sinon.stub().returns(settingsWithCharCount), viewer: sinon.stub().returns(commenters[0]), - story: sinon.stub().returns(stories[0]), + stream: sinon.stub().returns(stories[0]), ...resolver.Query, }, }; diff --git a/src/core/client/stream/test/comments/stream/charCountPostComment.spec.tsx b/src/core/client/stream/test/comments/stream/charCountPostComment.spec.tsx index 4db8c99d3..2bddc78f6 100644 --- a/src/core/client/stream/test/comments/stream/charCountPostComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/charCountPostComment.spec.tsx @@ -32,7 +32,7 @@ async function createTestRenderer( Query: { settings: sinon.stub().returns(settingsWithCharCount), viewer: sinon.stub().returns(commenters[0]), - story: sinon.stub().returns(stories[0]), + stream: sinon.stub().returns(stories[0]), ...resolver.Query, }, }; diff --git a/src/core/client/stream/test/comments/stream/charCountReplyComment.spec.tsx b/src/core/client/stream/test/comments/stream/charCountReplyComment.spec.tsx index 1ce3d83af..df7bef2c5 100644 --- a/src/core/client/stream/test/comments/stream/charCountReplyComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/charCountReplyComment.spec.tsx @@ -32,7 +32,7 @@ async function createTestRenderer( Query: { settings: sinon.stub().returns(settingsWithCharCount), viewer: sinon.stub().returns(commenters[0]), - story: sinon.stub().returns(stories[0]), + stream: sinon.stub().returns(stories[0]), ...resolver.Query, }, }; diff --git a/src/core/client/stream/test/comments/stream/closedOrDisabledCommentStream.spec.tsx b/src/core/client/stream/test/comments/stream/closedOrDisabledCommentStream.spec.tsx index 57ee5e5ec..889e88e59 100644 --- a/src/core/client/stream/test/comments/stream/closedOrDisabledCommentStream.spec.tsx +++ b/src/core/client/stream/test/comments/stream/closedOrDisabledCommentStream.spec.tsx @@ -15,7 +15,7 @@ async function createTestRenderer( ...resolver, Query: { settings: sinon.stub().returns(settings), - story: sinon.stub().callsFake((_: any, variables: any) => { + stream: sinon.stub().callsFake((_: any, variables: any) => { expectAndFail(variables.id).toBe(stories[0].id); return stories[0]; }), @@ -59,7 +59,7 @@ it("renders disabled comment stream", async () => { it("renders closed comment stream", async () => { const { testRenderer } = await createTestRenderer({ Query: { - story: sinon.stub().callsFake(() => ({ + stream: sinon.stub().callsFake(() => ({ ...stories[0], isClosed: true, })), @@ -78,7 +78,7 @@ it("auto close comment stream when story closed at has been reached", async () = const { testRenderer } = await createTestRenderer({ Query: { - story: sinon.stub().callsFake(() => ({ + stream: sinon.stub().callsFake(() => ({ ...stories[0], closedAt: later.toISOString(), isClosed: false, diff --git a/src/core/client/stream/test/comments/stream/editComment.spec.tsx b/src/core/client/stream/test/comments/stream/editComment.spec.tsx index 5f7965417..64d37e329 100644 --- a/src/core/client/stream/test/comments/stream/editComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/editComment.spec.tsx @@ -23,7 +23,7 @@ function createTestRenderer( ) { const resolvers = { Query: { - story: createSinonStub( + stream: createSinonStub( s => s.throws(), s => s diff --git a/src/core/client/stream/test/comments/stream/emitCountEvents.spec.tsx b/src/core/client/stream/test/comments/stream/emitCountEvents.spec.tsx index ce80b15fc..7ef325353 100644 --- a/src/core/client/stream/test/comments/stream/emitCountEvents.spec.tsx +++ b/src/core/client/stream/test/comments/stream/emitCountEvents.spec.tsx @@ -21,7 +21,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => viewer, - story: () => story, + stream: () => story, }, }), params.resolvers diff --git a/src/core/client/stream/test/comments/stream/ignoreUser.spec.tsx b/src/core/client/stream/test/comments/stream/ignoreUser.spec.tsx index 9b360473c..37cfba7c5 100644 --- a/src/core/client/stream/test/comments/stream/ignoreUser.spec.tsx +++ b/src/core/client/stream/test/comments/stream/ignoreUser.spec.tsx @@ -31,7 +31,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => viewer, - story: () => story, + stream: () => story, }, }), params.resolvers diff --git a/src/core/client/stream/test/comments/stream/liveCommentReplies.spec.tsx b/src/core/client/stream/test/comments/stream/liveCommentReplies.spec.tsx index 7122381fe..aa80dd95b 100644 --- a/src/core/client/stream/test/comments/stream/liveCommentReplies.spec.tsx +++ b/src/core/client/stream/test/comments/stream/liveCommentReplies.spec.tsx @@ -133,7 +133,7 @@ async function createTestRenderer( createResolversStub({ Query: { settings: () => settings, - story: () => story, + stream: () => story, }, }), params.resolvers @@ -223,7 +223,7 @@ it("should not subscribe when story is closed", async () => { const { testRenderer, subscriptionHandler } = await createTestRenderer({ resolvers: createResolversStub({ Query: { - story: () => pureMerge(story, { isClosed: true }), + stream: () => pureMerge(story, { isClosed: true }), }, }), }); diff --git a/src/core/client/stream/test/comments/stream/liveTopLevelComments.spec.tsx b/src/core/client/stream/test/comments/stream/liveTopLevelComments.spec.tsx index 433e16a51..0965529eb 100644 --- a/src/core/client/stream/test/comments/stream/liveTopLevelComments.spec.tsx +++ b/src/core/client/stream/test/comments/stream/liveTopLevelComments.spec.tsx @@ -25,7 +25,7 @@ async function createTestRenderer( createResolversStub({ Query: { settings: () => settings, - story: () => story, + stream: () => story, }, }), params.resolvers @@ -123,7 +123,7 @@ it("should not subscribe when story is closed", async () => { const { testRenderer, subscriptionHandler } = await createTestRenderer({ resolvers: createResolversStub({ Query: { - story: () => pureMerge(story, { isClosed: true }), + stream: () => pureMerge(story, { isClosed: true }), }, }), }); diff --git a/src/core/client/stream/test/comments/stream/loadMore.spec.tsx b/src/core/client/stream/test/comments/stream/loadMore.spec.tsx index 43f4eea63..095da8bca 100644 --- a/src/core/client/stream/test/comments/stream/loadMore.spec.tsx +++ b/src/core/client/stream/test/comments/stream/loadMore.spec.tsx @@ -81,6 +81,18 @@ beforeEach(() => { ) .returns(storyStub) ), + stream: createSinonStub( + s => s.throws(), + s => + s + .withArgs( + undefined, + sinon + .match({ id: storyStub.id, url: null }) + .or(sinon.match({ id: storyStub.id })) + ) + .returns(storyStub) + ), settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/comments/stream/memberSince.spec.tsx b/src/core/client/stream/test/comments/stream/memberSince.spec.tsx index 6333430c3..5c57ffa80 100644 --- a/src/core/client/stream/test/comments/stream/memberSince.spec.tsx +++ b/src/core/client/stream/test/comments/stream/memberSince.spec.tsx @@ -24,7 +24,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => viewer, - story: () => story, + stream: () => story, }, }), params.resolvers diff --git a/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/src/core/client/stream/test/comments/stream/moderation.spec.tsx index 59da6ace2..8b37be323 100644 --- a/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -37,7 +37,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => viewer, - story: () => story, + stream: () => story, }, }), params.resolvers diff --git a/src/core/client/stream/test/comments/stream/postComment.spec.tsx b/src/core/client/stream/test/comments/stream/postComment.spec.tsx index 90e484879..8534f32ea 100644 --- a/src/core/client/stream/test/comments/stream/postComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/postComment.spec.tsx @@ -30,7 +30,7 @@ async function createTestRenderer( Query: { settings: sinon.stub().returns(settings), viewer: sinon.stub().returns(commenters[0]), - story: sinon.stub().callsFake((_: any, variables: any) => { + stream: sinon.stub().callsFake((_: any, variables: any) => { expectAndFail(variables.id).toBe(stories[0].id); return stories[0]; }), diff --git a/src/core/client/stream/test/comments/stream/postLocalReply.spec.tsx b/src/core/client/stream/test/comments/stream/postLocalReply.spec.tsx index 62a732c0a..3e967c581 100644 --- a/src/core/client/stream/test/comments/stream/postLocalReply.spec.tsx +++ b/src/core/client/stream/test/comments/stream/postLocalReply.spec.tsx @@ -22,7 +22,7 @@ beforeEach(() => { Query: { settings: sinon.stub().returns(settings), viewer: sinon.stub().returns(commenters[0]), - story: createSinonStub( + stream: createSinonStub( s => s.throws(), s => s diff --git a/src/core/client/stream/test/comments/stream/postReply.spec.tsx b/src/core/client/stream/test/comments/stream/postReply.spec.tsx index 35ab4d028..66ad86ab4 100644 --- a/src/core/client/stream/test/comments/stream/postReply.spec.tsx +++ b/src/core/client/stream/test/comments/stream/postReply.spec.tsx @@ -26,7 +26,7 @@ async function createTestRenderer( Query: { settings: sinon.stub().returns(settings), viewer: sinon.stub().returns(commenters[0]), - story: sinon.stub().callsFake((_: any, variables: any) => { + stream: sinon.stub().callsFake((_: any, variables: any) => { expectAndFail(variables.id).toBe(stories[0].id); return stories[0]; }), diff --git a/src/core/client/stream/test/comments/stream/reaction.spec.tsx b/src/core/client/stream/test/comments/stream/reaction.spec.tsx index df40956e6..7a3d2ab38 100644 --- a/src/core/client/stream/test/comments/stream/reaction.spec.tsx +++ b/src/core/client/stream/test/comments/stream/reaction.spec.tsx @@ -8,7 +8,7 @@ import create from "./create"; function createTestRenderer() { const resolvers = { Query: { - story: sinon.stub().callsFake((_: any, data: any) => { + stream: sinon.stub().callsFake((_: any, data: any) => { expectAndFail(data).toEqual({ id: stories[0].id, url: null, diff --git a/src/core/client/stream/test/comments/stream/renderCommunityGuidelines.spec.tsx b/src/core/client/stream/test/comments/stream/renderCommunityGuidelines.spec.tsx index 692912ca1..10a88e368 100644 --- a/src/core/client/stream/test/comments/stream/renderCommunityGuidelines.spec.tsx +++ b/src/core/client/stream/test/comments/stream/renderCommunityGuidelines.spec.tsx @@ -8,7 +8,7 @@ import create from "./create"; function createTestRenderer() { const resolvers = { Query: { - story: sinon.stub().returns(stories[0]), + stream: sinon.stub().returns(stories[0]), settings: sinon.stub().returns({ ...settings, communityGuidelines: { diff --git a/src/core/client/stream/test/comments/stream/renderMessageBox.spec.tsx b/src/core/client/stream/test/comments/stream/renderMessageBox.spec.tsx index c6211eba0..2f4d4745e 100644 --- a/src/core/client/stream/test/comments/stream/renderMessageBox.spec.tsx +++ b/src/core/client/stream/test/comments/stream/renderMessageBox.spec.tsx @@ -18,7 +18,7 @@ async function createTestRenderer( Query: { settings: sinon.stub().returns(pureMerge(settings, data.settings)), viewer: sinon.stub().returns((data.loggedIn && commenters[0]) || null), - story: sinon.stub().callsFake((_: any, variables: any) => { + stream: sinon.stub().callsFake((_: any, variables: any) => { expectAndFail(variables.id).toBe(storyWithNoComments.id); return pureMerge(storyWithNoComments, data.story); }), diff --git a/src/core/client/stream/test/comments/stream/renderReplies.spec.tsx b/src/core/client/stream/test/comments/stream/renderReplies.spec.tsx index 86f3bfe97..95ed44782 100644 --- a/src/core/client/stream/test/comments/stream/renderReplies.spec.tsx +++ b/src/core/client/stream/test/comments/stream/renderReplies.spec.tsx @@ -14,7 +14,7 @@ let testRenderer: ReactTestRenderer; beforeEach(() => { const resolvers = { Query: { - story: createSinonStub( + stream: createSinonStub( s => s.throws(), s => s diff --git a/src/core/client/stream/test/comments/stream/renderStream.spec.tsx b/src/core/client/stream/test/comments/stream/renderStream.spec.tsx index 24759af19..0e58a1ca5 100644 --- a/src/core/client/stream/test/comments/stream/renderStream.spec.tsx +++ b/src/core/client/stream/test/comments/stream/renderStream.spec.tsx @@ -15,7 +15,7 @@ async function createTestRenderer( ...resolver, Query: { settings: sinon.stub().returns(settings), - story: sinon.stub().callsFake((_: any, variables: any) => { + stream: sinon.stub().callsFake((_: any, variables: any) => { expectAndFail(variables.id).toBe(story.id); return story; }), diff --git a/src/core/client/stream/test/comments/stream/reportComment.spec.tsx b/src/core/client/stream/test/comments/stream/reportComment.spec.tsx index d4cac6d41..6c17047af 100644 --- a/src/core/client/stream/test/comments/stream/reportComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/reportComment.spec.tsx @@ -14,7 +14,7 @@ function createTestRenderer( ) { const resolvers = { Query: { - story: sinon.stub().callsFake((_: any, data: any) => { + stream: sinon.stub().callsFake((_: any, data: any) => { expectAndFail(data).toEqual({ id: stories[0].id, url: null, diff --git a/src/core/client/stream/test/comments/stream/showAllReplies.spec.tsx b/src/core/client/stream/test/comments/stream/showAllReplies.spec.tsx index 10c2ebdeb..d1662fcb0 100644 --- a/src/core/client/stream/test/comments/stream/showAllReplies.spec.tsx +++ b/src/core/client/stream/test/comments/stream/showAllReplies.spec.tsx @@ -75,7 +75,7 @@ beforeEach(() => { s => s.throws(), s => s.withArgs(undefined, { id: commentStub.id }).returns(commentStub) ), - story: createSinonStub( + stream: createSinonStub( s => s.throws(), s => s diff --git a/src/core/client/stream/test/comments/stream/showConversation.spec.tsx b/src/core/client/stream/test/comments/stream/showConversation.spec.tsx index e0789eb33..799e4aa38 100644 --- a/src/core/client/stream/test/comments/stream/showConversation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/showConversation.spec.tsx @@ -19,6 +19,10 @@ beforeEach(() => { s => s.throws(), s => s.returns(storyWithDeepestReplies) ), + stream: createSinonStub( + s => s.throws(), + s => s.returns(storyWithDeepestReplies) + ), comment: createSinonStub( s => s.throws(), s => diff --git a/src/core/client/stream/test/comments/stream/sortStream.spec.tsx b/src/core/client/stream/test/comments/stream/sortStream.spec.tsx index ed98fc3b2..100beaa3f 100644 --- a/src/core/client/stream/test/comments/stream/sortStream.spec.tsx +++ b/src/core/client/stream/test/comments/stream/sortStream.spec.tsx @@ -62,7 +62,7 @@ it("renders app with comment stream", async () => { const { testRenderer } = await createTestRenderer({ Query: { - story: storyQueryStub, + stream: storyQueryStub, }, }); diff --git a/src/core/client/stream/test/comments/stream/suspended.spec.tsx b/src/core/client/stream/test/comments/stream/suspended.spec.tsx index b31bf4fab..242f23f11 100644 --- a/src/core/client/stream/test/comments/stream/suspended.spec.tsx +++ b/src/core/client/stream/test/comments/stream/suspended.spec.tsx @@ -34,7 +34,7 @@ async function createTestRenderer( }, }, }), - story: () => + stream: () => pureMerge(story, { comments: { edges: [ diff --git a/src/core/client/stream/test/profile/account.spec.tsx b/src/core/client/stream/test/profile/account.spec.tsx index 24916348f..0b604a8be 100644 --- a/src/core/client/stream/test/profile/account.spec.tsx +++ b/src/core/client/stream/test/profile/account.spec.tsx @@ -33,7 +33,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => viewer, - story: () => story, + stream: () => story, }, }), params.resolvers diff --git a/src/core/client/stream/test/profile/changeEmail.spec.tsx b/src/core/client/stream/test/profile/changeEmail.spec.tsx index 0d2e927e0..965db7e17 100644 --- a/src/core/client/stream/test/profile/changeEmail.spec.tsx +++ b/src/core/client/stream/test/profile/changeEmail.spec.tsx @@ -24,7 +24,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => baseUser, - story: () => story, + stream: () => story, }, }), params.resolvers diff --git a/src/core/client/stream/test/profile/changeUsername.spec.tsx b/src/core/client/stream/test/profile/changeUsername.spec.tsx index 984c411ae..fc0b1e983 100644 --- a/src/core/client/stream/test/profile/changeUsername.spec.tsx +++ b/src/core/client/stream/test/profile/changeUsername.spec.tsx @@ -32,7 +32,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => viewer, - story: () => story, + stream: () => story, }, }), params.resolvers diff --git a/src/core/client/stream/test/profile/deleteAccount.spec.tsx b/src/core/client/stream/test/profile/deleteAccount.spec.tsx index df5986820..01d2adcc8 100644 --- a/src/core/client/stream/test/profile/deleteAccount.spec.tsx +++ b/src/core/client/stream/test/profile/deleteAccount.spec.tsx @@ -25,7 +25,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => baseUser, - story: () => story, + stream: () => story, }, }), params.resolvers diff --git a/src/core/client/stream/test/profile/myComments.spec.tsx b/src/core/client/stream/test/profile/myComments.spec.tsx index 54d3edd51..9ffeac707 100644 --- a/src/core/client/stream/test/profile/myComments.spec.tsx +++ b/src/core/client/stream/test/profile/myComments.spec.tsx @@ -59,7 +59,7 @@ beforeEach(() => { const resolvers = { Query: { settings: sinon.stub().returns(settings), - story: createSinonStub( + stream: createSinonStub( s => s.throws(), s => s diff --git a/src/core/client/stream/test/profile/notificationSettings.spec.tsx b/src/core/client/stream/test/profile/notificationSettings.spec.tsx index a75bc5af5..acbf8c111 100644 --- a/src/core/client/stream/test/profile/notificationSettings.spec.tsx +++ b/src/core/client/stream/test/profile/notificationSettings.spec.tsx @@ -26,7 +26,7 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => viewer, - story: () => story, + stream: () => story, }, }), params.resolvers diff --git a/src/core/common/errors.ts b/src/core/common/errors.ts index 49ae6e621..f630cfa7c 100644 --- a/src/core/common/errors.ts +++ b/src/core/common/errors.ts @@ -327,4 +327,10 @@ export enum ERROR_CODES { * in a row within a given time frame */ REPEAT_POST = "REPEAT_POST", + + /** + * INSTALLATION_FORBIDDEN is returned when an installation is attempted + * when it is not authorized to do so. + */ + INSTALLATION_FORBIDDEN = "INSTALLATION_FORBIDDEN", } diff --git a/src/core/server/app/handlers/api/install.ts b/src/core/server/app/handlers/api/install.ts index ac6a87b5f..6d3a3d047 100644 --- a/src/core/server/app/handlers/api/install.ts +++ b/src/core/server/app/handlers/api/install.ts @@ -5,12 +5,88 @@ import { LanguageCode, LOCALES } from "coral-common/helpers/i18n/locales"; import { Omit } from "coral-common/types"; import { AppOptions } from "coral-server/app"; import { validate } from "coral-server/app/request/body"; -import { TenantInstalledAlreadyError } from "coral-server/errors"; -import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; +import { RequestLimiter } from "coral-server/app/request/limiter"; +import { Config } from "coral-server/config"; +import { + InstallationForbiddenError, + TenantInstalledAlreadyError, +} from "coral-server/errors"; import { LocalProfile } from "coral-server/models/user"; -import { install, InstallTenant } from "coral-server/services/tenant"; +import { + createJWTSigningConfig, + extractTokenFromRequest, + JWTSigningConfig, +} from "coral-server/services/jwt"; +import { verifyInstallationTokenString } from "coral-server/services/management"; +import { + install, + InstallTenant, + isInstalled, +} from "coral-server/services/tenant"; import { create, CreateUser } from "coral-server/services/users"; -import { RequestHandler } from "coral-server/types/express"; +import { Request, RequestHandler } from "coral-server/types/express"; + +import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; + +export type TenantInstallCheckHandlerOptions = Pick< + AppOptions, + "redis" | "config" +>; + +export const installCheckHandler = ({ + config, + redis, +}: TenantInstallCheckHandlerOptions): RequestHandler => { + const { managementEnabled, signingConfig } = managementSigningConfig(config); + const limiter = new RequestLimiter({ + redis, + ttl: "10s", + max: 2, + prefix: "ip", + config, + }); + + return async (req, res, next) => { + try { + // Limit based on the IP address. + await limiter.test(req, req.ip); + + if (!req.coral) { + return next(new Error("coral was not set")); + } + + 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. + return next(new TenantInstalledAlreadyError()); + } + + // Check to see if the server already has a tenant installed. + const alreadyInstalled = await isInstalled(req.coral.cache.tenant); + if (!alreadyInstalled) { + // No tenants are installed at all, we can of course proceed with the + // install now. + return res.sendStatus(204); + } + + // Check to see if management is enabled for this server. + if (managementEnabled && signingConfig) { + await checkForInstallationToken(req, signingConfig); + + // We've determined that there is already a tenant installed on this + // server, but we have a valid management token, so we're good! + return res.sendStatus(204); + } + + return next(new TenantInstalledAlreadyError()); + } catch (err) { + return next(err); + } + }; +}; export interface TenantInstallBody { tenant: Omit & { @@ -63,83 +139,162 @@ export const installHandler = ({ config, i18n, migrationManager, -}: TenantInstallHandlerOptions): RequestHandler => async (req, res, next) => { - try { - if (!req.coral) { - return next(new Error("coral was not set")); +}: TenantInstallHandlerOptions): RequestHandler => { + const { managementEnabled, signingConfig } = managementSigningConfig(config); + const limiter = new RequestLimiter({ + redis, + ttl: "10s", + max: 1, + prefix: "ip", + config, + }); + + return async (req, res, next) => { + try { + // Limit based on the IP address. + await limiter.test(req, req.ip); + + if (!req.coral) { + return next(new Error("coral was not set")); + } + + 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. + return next(new TenantInstalledAlreadyError()); + } + + // Check to see if the server already has a tenant installed. + let alreadyInstalled = await isInstalled(req.coral.cache.tenant); + + // Check to see if management is enabled for this server. + if (managementEnabled && signingConfig) { + // Management is enabled for this server, check now if the server already + // has a tenant installed. + if (alreadyInstalled) { + await checkForInstallationToken(req, signingConfig); + + // We've determined that there is at least one tenant already + // installed, and we've verified that the current call to install + // this tenant came with a signed token that was signed by the + // management secret, so we can safely mark that this tenant is + // indeed, not already been installed. + alreadyInstalled = false; + } + } + + // Guard against installs trying to install multiple tenants when management + // hasn't been enabled. + if (alreadyInstalled) { + return next(new TenantInstalledAlreadyError()); + } + + // Validate that the payload passed in was correct, it will throw if the + // payload is invalid. + const { + tenant: { locale: tenantLocale, ...tenantInput }, + user: userInput, + }: TenantInstallBody = validate(TenantInstallBodySchema, req.body); + + // Default the locale to the default locale if not provided. + let locale = tenantLocale; + if (!locale) { + locale = config.get("default_locale") as LanguageCode; + } + + // Install will throw if it can not create a Tenant, or it has already been + // installed. + const tenant = await install( + mongo, + redis, + req.coral.cache.tenant, + i18n, + { + ...tenantInput, + // Infer the Tenant domain via the hostname parameter. + domain: req.hostname, + // Add the locale that we had to default to the default locale from the + // config. + locale, + }, + req.coral.now + ); + + // Pull the user details out of the input for the user. + const { email, username, password } = userInput; + + // Configure with profile. + const profile: LocalProfile = { + type: "local", + id: email, + password, + passwordID: uuid(), + }; + + // Create the first admin user. + await create( + mongo, + tenant, + { + email, + username, + profile, + role: GQLUSER_ROLE.ADMIN, + }, + {}, + req.coral.now + ); + + // Execute pending migrations to get everything installed. + await migrationManager.executePendingMigrations(mongo, true); + + // Send back the Tenant. + return res.sendStatus(204); + } catch (err) { + return next(err); } - - 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. - return next(new TenantInstalledAlreadyError()); - } - - // Validate that the payload passed in was correct, it will throw if the - // payload is invalid. - const { - tenant: { locale: tenantLocale, ...tenantInput }, - user: userInput, - }: TenantInstallBody = validate(TenantInstallBodySchema, req.body); - - // Default the locale to the default locale if not provided. - let locale = tenantLocale; - if (!locale) { - locale = config.get("default_locale") as LanguageCode; - } - - // Install will throw if it can not create a Tenant, or it has already been - // installed. - const tenant = await install( - mongo, - redis, - req.coral.cache.tenant, - i18n, - { - ...tenantInput, - // Infer the Tenant domain via the hostname parameter. - domain: req.hostname, - // Add the locale that we had to default to the default locale from the - // config. - locale, - }, - req.coral.now - ); - - // Execute pending migrations to get everything installed. - await migrationManager.executePendingMigrations(mongo); - - // Pull the user details out of the input for the user. - const { email, username, password } = userInput; - - // Configure with profile. - const profile: LocalProfile = { - type: "local", - id: email, - password, - passwordID: uuid(), - }; - - // Create the first admin user. - await create( - mongo, - tenant, - { - email, - username, - profile, - role: GQLUSER_ROLE.ADMIN, - }, - {}, - req.coral.now - ); - - // Send back the Tenant. - return res.sendStatus(204); - } catch (err) { - return next(err); - } + }; }; + +async function checkForInstallationToken( + req: Request, + signingConfig: JWTSigningConfig +) { + // The server already has another tenant installed. Every additional + // tenant must be installed via the signed domain method. Check to see + // now if the given domain is signed. + const accessToken = extractTokenFromRequest(req, true); + if (accessToken) { + // Verify the JWT on the request to ensure it was signed by the + // management secret. + const { token } = await verifyInstallationTokenString( + signingConfig, + accessToken, + req.coral!.now + ); + + // Check to see that the domain on the token matches the hostname on + // the request. + if (req.hostname !== token.sub) { + throw new InstallationForbiddenError(req.hostname); + } + } else { + throw new InstallationForbiddenError(req.hostname); + } +} + +function managementSigningConfig(config: Config) { + const managementSigningSecret = config.get("management_signing_secret"); + const managementSigningAlgorithm = config.get("management_signing_algorithm"); + const managementEnabled = Boolean(managementSigningSecret); + const signingConfig = managementSigningSecret + ? createJWTSigningConfig( + managementSigningSecret, + managementSigningAlgorithm + ) + : null; + return { managementEnabled, signingConfig }; +} diff --git a/src/core/server/app/middleware/installed.ts b/src/core/server/app/middleware/installed.ts index aa6d7d3e5..810020506 100644 --- a/src/core/server/app/middleware/installed.ts +++ b/src/core/server/app/middleware/installed.ts @@ -29,7 +29,7 @@ export const installedMiddleware = ({ return next(new Error("cache was not set")); } - const installed = await isInstalled(req.coral.cache.tenant); + 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 diff --git a/src/core/server/app/middleware/logging.ts b/src/core/server/app/middleware/logging.ts index 7658c2105..a78f8c448 100644 --- a/src/core/server/app/middleware/logging.ts +++ b/src/core/server/app/middleware/logging.ts @@ -26,6 +26,7 @@ export const accessLogger: RequestHandler = (req, res, next) => { url: req.originalUrl || req.url, method: req.method, statusCode: res.statusCode, + host: req.hostname, userAgent, responseTime, }, diff --git a/src/core/server/app/request/limiter.ts b/src/core/server/app/request/limiter.ts index 0803f0403..171fede7f 100644 --- a/src/core/server/app/request/limiter.ts +++ b/src/core/server/app/request/limiter.ts @@ -1,6 +1,7 @@ /* eslint-disable max-classes-per-file */ import { Redis } from "ioredis"; +import { DateTime } from "luxon"; import ms from "ms"; import { Omit } from "coral-common/types"; @@ -68,7 +69,10 @@ export class Limiter { } if (tries > this.max) { - throw new RateLimitExceeded(key, this.max, tries); + const resetsAt = DateTime.fromJSDate(new Date()) + .plus({ seconds: this.ttl }) + .toJSDate(); + throw new RateLimitExceeded(key, this.max, resetsAt, tries); } return tries; diff --git a/src/core/server/app/router/api/index.ts b/src/core/server/app/router/api/index.ts index d8652864a..b5069abfc 100644 --- a/src/core/server/app/router/api/index.ts +++ b/src/core/server/app/router/api/index.ts @@ -5,7 +5,6 @@ import { AppOptions } from "coral-server/app"; import { graphQLHandler, healthHandler, - installHandler, versionHandler, } from "coral-server/app/handlers"; import { JSONErrorHandler } from "coral-server/app/middleware/error"; @@ -18,6 +17,7 @@ import { tenantMiddleware } from "coral-server/app/middleware/tenant"; import { createNewAccountRouter } from "./account"; import { createNewAuthRouter } from "./auth"; +import { createNewInstallRouter } from "./install"; import { createStoryRouter } from "./story"; import { createNewUserRouter } from "./user"; @@ -39,13 +39,8 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) { // Configure the Health route. router.get("/health", healthHandler); - // Installation middleware. - router.use( - "/install", - jsonMiddleware, - tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }), - installHandler(app) - ); + // Installation router. + router.use("/install", createNewInstallRouter(app)); // Tenant identification middleware. All requests going past this point can // only proceed if there is a valid Tenant for the hostname. diff --git a/src/core/server/app/router/api/install.ts b/src/core/server/app/router/api/install.ts new file mode 100644 index 000000000..eb437619a --- /dev/null +++ b/src/core/server/app/router/api/install.ts @@ -0,0 +1,25 @@ +import express, { Router } from "express"; + +import { AppOptions } from "coral-server/app"; +import { installCheckHandler, installHandler } from "coral-server/app/handlers"; +import { jsonMiddleware } from "coral-server/app/middleware/json"; +import { tenantMiddleware } from "coral-server/app/middleware/tenant"; + +export function createNewInstallRouter(app: AppOptions): Router { + // Create a router. + const router = express.Router(); + + router.get( + "/", + tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }), + installCheckHandler(app) + ); + router.post( + "/", + jsonMiddleware, + tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }), + installHandler(app) + ); + + return router; +} diff --git a/src/core/server/config.ts b/src/core/server/config.ts index bb702739f..9a5dee7bc 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -45,6 +45,18 @@ convict.addFormat({ coerce: (url: string) => (url ? ensureEndSlash(url) : url), }); +const algorithms = [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", +]; + const config = convict({ env: { doc: "The application environment.", @@ -150,22 +162,27 @@ const config = convict({ sensitive: true, }, signing_algorithm: { - doc: "", - format: [ - "HS256", - "HS384", - "HS512", - "RS256", - "RS384", - "RS512", - "ES256", - "ES384", - "ES512", - ], + doc: "The signing algorithm used to sign JSON Web Tokens (JWT).", + format: algorithms, default: "HS256", env: "SIGNING_ALGORITHM", arg: "signingAlgorithm", }, + management_signing_secret: { + doc: "The secret used to verify management API requests.", + format: "*", + default: null, + env: "MANAGEMENT_SIGNING_SECRET", + arg: "managementSigningSecret", + sensitive: true, + }, + management_signing_algorithm: { + doc: "The algorithm used to sign management API requests", + format: algorithms, + default: "HS256", + env: "MANAGEMENT_SIGNING_ALGORITHM", + arg: "managementSigningAlgorithm", + }, logging_level: { doc: "The logging level to print to the console", format: ["fatal", "error", "warn", "info", "debug", "trace"], diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index 2b6924867..dfc2a03c8 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -530,6 +530,16 @@ export class TenantInstalledAlreadyError extends CoralError { } } +export class InstallationForbiddenError extends CoralError { + constructor(domain: string) { + super({ + code: ERROR_CODES.INSTALLATION_FORBIDDEN, + status: 401, + context: { pub: { domain } }, + }); + } +} + export class InvalidCredentialsError extends CoralError { constructor(reason: string) { super({ @@ -685,11 +695,11 @@ export class InviteTokenExpired extends CoralError { } export class RateLimitExceeded extends CoralError { - constructor(resource: string, max: number, tries?: number) { + constructor(resource: string, max: number, resetsAt: Date, tries?: number) { super({ code: ERROR_CODES.RATE_LIMIT_EXCEEDED, status: 429, - context: { pvt: { resource, max, tries } }, + context: { pvt: { resource, max, tries, resetsAt } }, }); } } diff --git a/src/core/server/errors/translations.ts b/src/core/server/errors/translations.ts index 2db1987b8..08a322e4e 100644 --- a/src/core/server/errors/translations.ts +++ b/src/core/server/errors/translations.ts @@ -57,4 +57,5 @@ export const ERROR_TRANSLATIONS: Record = { USER_ALREADY_PREMOD: "error-userAlreadyPremod", INVITE_INCLUDES_EXISTING_USER: "error-inviteIncludesExistingUser", REPEAT_POST: "error-repeatPost", + INSTALLATION_FORBIDDEN: "error-installationForbidden", }; diff --git a/src/core/server/graph/tenant/resolvers/Query.ts b/src/core/server/graph/tenant/resolvers/Query.ts index 60e77ee8b..a840096bd 100644 --- a/src/core/server/graph/tenant/resolvers/Query.ts +++ b/src/core/server/graph/tenant/resolvers/Query.ts @@ -3,7 +3,8 @@ import { GQLQueryTypeResolver } from "coral-server/graph/tenant/schema/__generat import { moderationQueuesResolver } from "./ModerationQueues"; export const Query: Required> = { - story: (source, args, ctx) => + story: (source, args, ctx) => ctx.loaders.Stories.find.load(args), + stream: (source, args, ctx) => ctx.tenant.stories.disableLazy ? ctx.loaders.Stories.find.load(args) : ctx.loaders.Stories.findOrCreate.load(args), diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 14b350b48..a03b76c40 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -2597,6 +2597,12 @@ type Query { """ story(id: ID, url: String): Story + """ + stream will load a specific story that can be identified by either an ID or a + URL and will create the story if that feature is enabled. + """ + stream(id: ID, url: String): Story + """ stories returns filtered stories that can be paginated. """ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index edc4ee395..d66913dbe 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -127,7 +127,10 @@ class Server { this.i18n = new I18n(defaultLocale); // Create the signing config. - this.signingConfig = createJWTSigningConfig(this.config); + this.signingConfig = createJWTSigningConfig( + this.config.get("signing_secret"), + this.config.get("signing_algorithm") + ); } /** diff --git a/src/core/server/locales/en-US/errors.ftl b/src/core/server/locales/en-US/errors.ftl index 64907b9eb..20e04210e 100644 --- a/src/core/server/locales/en-US/errors.ftl +++ b/src/core/server/locales/en-US/errors.ftl @@ -36,7 +36,7 @@ error-emailInvalidFormat = error-emailExceedsMaxLength = Email address exceeds maximum length of {$max} characters. error-internalError = Internal Error -error-tenantInstalledAlready = Tenant has already been installed already. +error-tenantInstalledAlready = Tenant has already been installed. error-userNotEntitled = You are not authorized to access that resource. error-storyNotFound = Story ({$storyID}) not found. error-commentNotFound = Comment ({$commentID}) not found. @@ -59,3 +59,4 @@ error-persistedQueryNotFound = The persisted query with ID { $id } was not found error-rawQueryNotAuthorized = You are not authorized to execute this query. error-inviteIncludesExistingUser = A user with the email address { $email } already exists. error-repeatPost = Are you sure? This comment is very similar to your previous comment. +error-installationForbidden = { -product-name } is already installed. To install another Tenant on this domain ({ $domain }) you need to generate an installation token. diff --git a/src/core/server/models/story/counts/index.ts b/src/core/server/models/story/counts/index.ts index 9ddffb691..53417a1fd 100644 --- a/src/core/server/models/story/counts/index.ts +++ b/src/core/server/models/story/counts/index.ts @@ -148,6 +148,7 @@ export async function updateStoryCounts( const update: DeepPartial = { commentCounts }; const $inc = pickBy(dotize(update), identity); if (isEmpty($inc)) { + // Nothing needs to be incremented, just return the story. return retrieveStory(mongo, tenantID, id); } diff --git a/src/core/server/models/tenant/tenant.ts b/src/core/server/models/tenant/tenant.ts index 71050814c..8ce72cea7 100644 --- a/src/core/server/models/tenant/tenant.ts +++ b/src/core/server/models/tenant/tenant.ts @@ -1,3 +1,4 @@ +import { isEmpty } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; @@ -259,11 +260,19 @@ export async function updateTenant( id: string, update: UpdateTenantInput ) { + const $set = dotize(update, { embedArrays: true }); + + // Check to see if there is any updates that will be made. + if (isEmpty($set)) { + // No updates need to be made, abort here and just return the tenant. + return retrieveTenant(mongo, id); + } + // 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 }) }, + { $set }, // False to return the updated document instead of the original // document. { returnOriginal: false } diff --git a/src/core/server/services/comments/pipeline/phases/userRateLimit.ts b/src/core/server/services/comments/pipeline/phases/userRateLimit.ts index 366b06983..4ee0c497d 100644 --- a/src/core/server/services/comments/pipeline/phases/userRateLimit.ts +++ b/src/core/server/services/comments/pipeline/phases/userRateLimit.ts @@ -52,7 +52,7 @@ export const userRateLimit: IntermediateModerationPhase = async ({ .plus({ seconds: COMMENT_LIMIT_WINDOW_SECONDS }) .toJSDate(); if (nextEditTime > now) { - throw new RateLimitExceeded("createComment", 1); + throw new RateLimitExceeded("createComment", 1, nextEditTime); } return; diff --git a/src/core/server/services/jwt/index.spec.ts b/src/core/server/services/jwt/index.spec.ts index 6fc9c687b..99def2a49 100644 --- a/src/core/server/services/jwt/index.spec.ts +++ b/src/core/server/services/jwt/index.spec.ts @@ -1,6 +1,3 @@ -import sinon from "sinon"; - -import { Config } from "coral-server/config"; import { createJWTSigningConfig, extractTokenFromRequest, @@ -50,14 +47,8 @@ describe("extractJWTFromRequest", () => { describe("createJWTSigningConfig", () => { it("parses a RSA certificate", () => { const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`; - const config = { - get: sinon.stub(), - }; - config.get.withArgs("signing_secret").returns(input); - config.get.withArgs("signing_algorithm").returns("RS256"); - - const signingConfig = createJWTSigningConfig((config as any) as Config); + const signingConfig = createJWTSigningConfig(input, "RS256"); expect(signingConfig.algorithm).toEqual("RS256"); expect(signingConfig.secret.toString()).toMatchSnapshot(); diff --git a/src/core/server/services/jwt/index.ts b/src/core/server/services/jwt/index.ts index bcdb643d7..ab57f1efd 100644 --- a/src/core/server/services/jwt/index.ts +++ b/src/core/server/services/jwt/index.ts @@ -9,7 +9,6 @@ import uuid from "uuid/v4"; import { DEFAULT_SESSION_LENGTH } from "coral-common/constants"; import { Omit } from "coral-common/types"; -import { Config } from "coral-server/config"; import { AuthenticationError, JWTRevokedError, @@ -230,12 +229,11 @@ function isAsymmetricSigningAlgorithm( /** * Parses the config and provides the signing config. - * - * @param config the server configuration */ -export function createJWTSigningConfig(config: Config): JWTSigningConfig { - const secret = config.get("signing_secret"); - const algorithm = config.get("signing_algorithm"); +export function createJWTSigningConfig( + secret: string, + algorithm: string = SymmetricSigningAlgorithm.HS256 +): JWTSigningConfig { if (isSymmetricSigningAlgorithm(algorithm)) { return createSymmetricSigningConfig(algorithm, secret); } else if (isAsymmetricSigningAlgorithm(algorithm)) { diff --git a/src/core/server/services/management/index.ts b/src/core/server/services/management/index.ts new file mode 100644 index 000000000..af41cea8f --- /dev/null +++ b/src/core/server/services/management/index.ts @@ -0,0 +1 @@ +export * from "./management"; diff --git a/src/core/server/services/management/management.ts b/src/core/server/services/management/management.ts new file mode 100644 index 000000000..1d5476776 --- /dev/null +++ b/src/core/server/services/management/management.ts @@ -0,0 +1,58 @@ +import Joi from "joi"; +import { isNull } from "lodash"; + +import { TokenInvalidError } from "coral-server/errors"; + +import { + JWTSigningConfig, + StandardClaims, + StandardClaimsSchema, + verifyJWT, +} from "../jwt"; + +export interface InstallationToken + extends Required> { + // aud specifies `installation` as the audience to indicate that this is a + // installation token. + aud: "installation"; +} + +const InstallationTokenSchema = StandardClaimsSchema.keys({ + aud: Joi.string().only("installation"), +}).requiredKeys(["iat", "exp", "sub", "aud"]); + +export function validateInstallationToken( + token: InstallationToken | object +): Error | null { + const { error } = Joi.validate(token, InstallationTokenSchema); + return error || null; +} + +export function isInstallationToken( + token: InstallationToken | object +): token is InstallationToken { + return isNull(validateInstallationToken(token)); +} + +export async function verifyInstallationTokenString( + signingConfig: JWTSigningConfig, + tokenString: string, + now: Date +) { + const token = verifyJWT(tokenString, signingConfig, now, { + // Verify that this is a installation token based on the audience. + audience: "installation", + }); + + // Validate that this is indeed a installation token. + if (!isInstallationToken(token)) { + // TODO: (wyattjoh) look into a way of pulling the error into this one + throw new TokenInvalidError( + tokenString, + "does not conform to the installation token schema" + ); + } + + // Now that we've verified that the token is valid, we're good to go! + return { token }; +} diff --git a/src/core/server/services/migrate/manager.ts b/src/core/server/services/migrate/manager.ts index 8b761455f..2a961d574 100644 --- a/src/core/server/services/migrate/manager.ts +++ b/src/core/server/services/migrate/manager.ts @@ -133,9 +133,13 @@ export default class Manager { return records.length > 0 ? records[records.length - 1] : null; } - public async executePendingMigrations(mongo: Db) { + public async executePendingMigrations(mongo: Db, silent = false) { // Error out if this is ran twice. if (this.ran) { + if (silent) { + return; + } + throw new Error("pending migrations have already been executed"); } diff --git a/src/core/server/services/stories/index.ts b/src/core/server/services/stories/index.ts index adb0f279c..3cb3d0931 100644 --- a/src/core/server/services/stories/index.ts +++ b/src/core/server/services/stories/index.ts @@ -1,4 +1,4 @@ -import { zip } from "lodash"; +import { uniq, zip } from "lodash"; import { Db } from "mongodb"; import { StoryURLInvalidError } from "coral-server/errors"; @@ -270,8 +270,13 @@ export async function merge( return null; } - // Get the stories referenced. + // Collect the story id's and check for duplicates. const storyIDs = [destinationID, ...sourceIDs]; + if (uniq(storyIDs).length !== storyIDs.length) { + throw new Error("cannot merge from/to the same story ID"); + } + + // Get the stories referenced. const stories = await retrieveManyStories(mongo, tenant.id, storyIDs); // Ensure that these are all defined. diff --git a/src/core/server/services/tenant/cache/index.ts b/src/core/server/services/tenant/cache/index.ts index c254eb144..81cb1d369 100644 --- a/src/core/server/services/tenant/cache/index.ts +++ b/src/core/server/services/tenant/cache/index.ts @@ -300,7 +300,10 @@ export default class TenantCache { JSON.stringify(message) ); - logger.debug({ tenantID: tenant.id, subscribers }, "updated tenant"); + logger.debug( + { tenantID: tenant.id, subscribers }, + "updated tenant in cache" + ); // Publish the event for the connected listeners. this.emitter.emit(EMITTER_EVENT_NAME, tenant); diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index 9df8b6df7..bb4cecc19 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -80,8 +80,22 @@ export async function update( * isInstalled will return a promise that if true, indicates that a Tenant has * been installed. */ -export async function isInstalled(cache: TenantCache) { - return (await cache.count()) > 0; +export async function isInstalled(cache: TenantCache, domain?: string) { + const count = await cache.count(); + if (count === 0) { + return false; + } + + if (domain) { + const tenant = await cache.retrieveByDomain(domain); + if (tenant) { + return true; + } + + return false; + } + + return true; } export type InstallTenant = CreateTenantInput; @@ -94,7 +108,9 @@ export async function install( input: InstallTenant, now = new Date() ) { - if (await isInstalled(cache)) { + // Ensure that this Tenant isn't being installed onto a domain that already + // exists. + if (await isInstalled(cache, input.domain)) { throw new TenantInstalledAlreadyError(); } diff --git a/src/core/server/services/users/users.ts b/src/core/server/services/users/users.ts index de1173db7..14e8f0bcb 100644 --- a/src/core/server/services/users/users.ts +++ b/src/core/server/services/users/users.ts @@ -1251,7 +1251,10 @@ export async function updateUserLastWroteCommentTimestamp( .expire(key, COMMENT_LIMIT_WINDOW_SECONDS) .exec(); if (!set) { - throw new RateLimitExceeded("createComment", 1); + const resetsAt = DateTime.fromJSDate(when) + .plus({ seconds: COMMENT_LIMIT_WINDOW_SECONDS }) + .toJSDate(); + throw new RateLimitExceeded("createComment", 1, resetsAt); } }