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 (
+
+ );
+ }
+
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 (
+
+ );
+ }
+
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);
}
}