mirror of
https://github.com/wassname/talk.git
synced 2026-08-11 11:27:10 +08:00
[CORL-628] CLI Support (#2646)
* feat: improved api for reloading tenants for cli * fix: cleaned up merge beheviour * feat: added support for stream/story edge * feat: support tenant install * fix: updated snapshot * fix: fixed tests
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.root}>
|
||||
<MainBar />
|
||||
<div className={styles.container}>
|
||||
<InstallWizard />
|
||||
</div>
|
||||
type State = "loading" | "success" | "failure";
|
||||
|
||||
const App: FunctionComponent = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [state, setState] = useState<State>("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 (
|
||||
<div className={styles.root}>
|
||||
<MainBar />
|
||||
<div className={styles.container}>
|
||||
<AppState state={state} error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AppStateProps {
|
||||
state: State;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AppState: FunctionComponent<AppStateProps> = ({ state, error }) => {
|
||||
switch (state) {
|
||||
case "loading":
|
||||
return null;
|
||||
case "success":
|
||||
return <InstallWizard />;
|
||||
default:
|
||||
return <FailedAppState error={error} />;
|
||||
}
|
||||
};
|
||||
|
||||
interface FailedAppStateProps {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const FailedAppState: FunctionComponent<FailedAppStateProps> = ({ error }) => (
|
||||
<Wizard currentStep={0}>
|
||||
<Flex justifyContent="center">
|
||||
<CallOut color="error">
|
||||
<Typography variant="bodyCopy">{error}</Typography>
|
||||
</CallOut>
|
||||
</Flex>
|
||||
</Wizard>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -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;
|
||||
@@ -8,7 +8,10 @@ exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="App-container"
|
||||
>
|
||||
<withContext(createMutationContainer(InstallWizard)) />
|
||||
<AppState
|
||||
error={null}
|
||||
state="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const INSTALL_ACCESS_TOKEN_KEY = "coral:install:accessToken";
|
||||
@@ -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 = () => (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as initLocalState } from "./initLocalState";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<QueryTypes["response"]>) => {
|
||||
return <div>{data.error.message}</div>;
|
||||
}
|
||||
if (data.props) {
|
||||
if (!data.props.story) {
|
||||
return (
|
||||
<Localized id="comments-streamQuery-storyNotFound">
|
||||
<div>Story not found</div>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SpinnerWhileRendering>
|
||||
<AllCommentsTabContainer
|
||||
settings={data.props.settings}
|
||||
viewer={data.props.viewer}
|
||||
story={data.props.story!}
|
||||
story={data.props.story}
|
||||
/>
|
||||
</SpinnerWhileRendering>
|
||||
);
|
||||
@@ -57,7 +66,7 @@ const AllCommentsTabQuery: FunctionComponent<Props> = props => {
|
||||
viewer {
|
||||
...AllCommentsTabContainer_viewer
|
||||
}
|
||||
story(id: $storyID, url: $storyURL) {
|
||||
story: stream(id: $storyID, url: $storyURL) {
|
||||
...AllCommentsTabContainer_story
|
||||
@arguments(orderBy: $commentsOrderBy)
|
||||
}
|
||||
|
||||
+13
-2
@@ -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<QueryTypes["response"]>) => {
|
||||
if (data.error) {
|
||||
return <div>{data.error.message}</div>;
|
||||
}
|
||||
|
||||
if (!data.props) {
|
||||
return (
|
||||
<Flex justifyContent="center">
|
||||
@@ -30,12 +32,21 @@ export const render = (data: ReadyState<QueryTypes["response"]>) => {
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.props) {
|
||||
if (!data.props.story) {
|
||||
return (
|
||||
<Localized id="comments-streamQuery-storyNotFound">
|
||||
<div>Story not found</div>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeaturedCommentsContainer
|
||||
settings={data.props.settings}
|
||||
viewer={data.props.viewer}
|
||||
story={data.props.story!}
|
||||
story={data.props.story}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +75,7 @@ const FeaturedCommentsQuery: FunctionComponent<Props> = props => {
|
||||
viewer {
|
||||
...FeaturedCommentsContainer_viewer
|
||||
}
|
||||
story(id: $storyID, url: $storyURL) {
|
||||
story: stream(id: $storyID, url: $storyURL) {
|
||||
...FeaturedCommentsContainer_story
|
||||
@arguments(orderBy: $commentsOrderBy)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ const StreamQuery: FunctionComponent<Props> = props => {
|
||||
viewer {
|
||||
...StreamContainer_viewer
|
||||
}
|
||||
story(id: $storyID, url: $storyURL) {
|
||||
story: stream(id: $storyID, url: $storyURL) {
|
||||
...StreamContainer_story
|
||||
}
|
||||
settings {
|
||||
|
||||
@@ -81,7 +81,7 @@ const ProfileQuery: FunctionComponent<Props> = ({
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query ProfileQuery($storyID: ID, $storyURL: String) {
|
||||
story(id: $storyID, url: $storyURL) {
|
||||
story: stream(id: $storyID, url: $storyURL) {
|
||||
...ProfileContainer_story
|
||||
}
|
||||
viewer {
|
||||
|
||||
@@ -18,38 +18,39 @@ const story = storyWithFeaturedComments;
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const storyResolver = () => ({
|
||||
...story,
|
||||
featuredComments: createQueryResolverStub<StoryToCommentsResolver>(
|
||||
({ 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<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
story: () => ({
|
||||
...story,
|
||||
featuredComments: createQueryResolverStub<StoryToCommentsResolver>(
|
||||
({ 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
|
||||
|
||||
@@ -22,7 +22,7 @@ async function createTestRenderer(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
story: () => ({
|
||||
stream: () => ({
|
||||
...story,
|
||||
featuredComments: createQueryResolverStub<StoryToCommentsResolver>(
|
||||
() => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => bannedUser,
|
||||
story: () =>
|
||||
stream: () =>
|
||||
pureMerge<typeof story>(story, {
|
||||
comments: {
|
||||
edges: [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,7 +23,7 @@ function createTestRenderer(
|
||||
) {
|
||||
const resolvers = {
|
||||
Query: {
|
||||
story: createSinonStub(
|
||||
stream: createSinonStub(
|
||||
s => s.throws(),
|
||||
s =>
|
||||
s
|
||||
|
||||
@@ -21,7 +21,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
story: () => story,
|
||||
stream: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
|
||||
@@ -31,7 +31,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
story: () => story,
|
||||
stream: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
|
||||
@@ -133,7 +133,7 @@ async function createTestRenderer(
|
||||
createResolversStub<GQLResolver>({
|
||||
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<GQLResolver>({
|
||||
Query: {
|
||||
story: () => pureMerge<typeof story>(story, { isClosed: true }),
|
||||
stream: () => pureMerge<typeof story>(story, { isClosed: true }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ async function createTestRenderer(
|
||||
createResolversStub<GQLResolver>({
|
||||
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<GQLResolver>({
|
||||
Query: {
|
||||
story: () => pureMerge<typeof story>(story, { isClosed: true }),
|
||||
stream: () => pureMerge<typeof story>(story, { isClosed: true }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
story: () => story,
|
||||
stream: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
|
||||
@@ -37,7 +37,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
story: () => story,
|
||||
stream: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
|
||||
@@ -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];
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,7 @@ let testRenderer: ReactTestRenderer;
|
||||
beforeEach(() => {
|
||||
const resolvers = {
|
||||
Query: {
|
||||
story: createSinonStub(
|
||||
stream: createSinonStub(
|
||||
s => s.throws(),
|
||||
s =>
|
||||
s
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -62,7 +62,7 @@ it("renders app with comment stream", async () => {
|
||||
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
Query: {
|
||||
story: storyQueryStub,
|
||||
stream: storyQueryStub,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ async function createTestRenderer(
|
||||
},
|
||||
},
|
||||
}),
|
||||
story: () =>
|
||||
stream: () =>
|
||||
pureMerge<typeof story>(story, {
|
||||
comments: {
|
||||
edges: [
|
||||
|
||||
@@ -33,7 +33,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
story: () => story,
|
||||
stream: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
|
||||
@@ -24,7 +24,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => baseUser,
|
||||
story: () => story,
|
||||
stream: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
|
||||
@@ -32,7 +32,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
story: () => story,
|
||||
stream: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
|
||||
@@ -25,7 +25,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => baseUser,
|
||||
story: () => story,
|
||||
stream: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
|
||||
@@ -59,7 +59,7 @@ beforeEach(() => {
|
||||
const resolvers = {
|
||||
Query: {
|
||||
settings: sinon.stub().returns(settings),
|
||||
story: createSinonStub(
|
||||
stream: createSinonStub(
|
||||
s => s.throws(),
|
||||
s =>
|
||||
s
|
||||
|
||||
@@ -26,7 +26,7 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
story: () => story,
|
||||
stream: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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<InstallTenant, "domain" | "locale"> & {
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+29
-12
@@ -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"],
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +57,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
|
||||
USER_ALREADY_PREMOD: "error-userAlreadyPremod",
|
||||
INVITE_INCLUDES_EXISTING_USER: "error-inviteIncludesExistingUser",
|
||||
REPEAT_POST: "error-repeatPost",
|
||||
INSTALLATION_FORBIDDEN: "error-installationForbidden",
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@ import { GQLQueryTypeResolver } from "coral-server/graph/tenant/schema/__generat
|
||||
import { moderationQueuesResolver } from "./ModerationQueues";
|
||||
|
||||
export const Query: Required<GQLQueryTypeResolver<void>> = {
|
||||
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),
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -148,6 +148,7 @@ export async function updateStoryCounts(
|
||||
const update: DeepPartial<Story> = { commentCounts };
|
||||
const $inc = pickBy(dotize(update), identity);
|
||||
if (isEmpty($inc)) {
|
||||
// Nothing needs to be incremented, just return the story.
|
||||
return retrieveStory(mongo, tenantID, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./management";
|
||||
@@ -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<Pick<StandardClaims, "iat" | "exp" | "sub">> {
|
||||
// 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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-1
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user