├── client
├── public
│ ├── robots.txt
│ ├── favicon.ico
│ ├── logo192.png
│ ├── logo512.png
│ ├── favicon_new.ico
│ ├── manifest.json
│ └── index.html
├── src
│ ├── routes
│ │ ├── history.js
│ │ └── index.js
│ ├── pages
│ │ ├── Login
│ │ │ └── index.js
│ │ ├── NotFound
│ │ │ └── index.js
│ │ ├── Index
│ │ │ └── index.js
│ │ ├── Reply
│ │ │ └── index.js
│ │ ├── Profile
│ │ │ └── index.js
│ │ ├── Settings
│ │ │ ├── Profile
│ │ │ │ └── index.js
│ │ │ └── Account
│ │ │ │ └── index.js
│ │ ├── Post
│ │ │ └── index.js
│ │ ├── Home
│ │ │ └── index.js
│ │ └── Search
│ │ │ └── index.js
│ ├── components
│ │ ├── Container
│ │ │ └── index.js
│ │ ├── AccentButton
│ │ │ ├── index.js
│ │ │ └── styles.js
│ │ ├── LoadMoreButton
│ │ │ └── index.js
│ │ ├── NotAvailableMessage
│ │ │ ├── styles.js
│ │ │ └── index.js
│ │ ├── HoverBox
│ │ │ └── index.js
│ │ ├── RequiredLabel
│ │ │ └── index.js
│ │ ├── CharacterCountLabel
│ │ │ └── index.js
│ │ ├── RichTabTitle
│ │ │ └── index.js
│ │ ├── Loader
│ │ │ ├── index.js
│ │ │ └── styles.js
│ │ ├── ContentList
│ │ │ └── index.js
│ │ ├── ProfileList
│ │ │ └── index.js
│ │ ├── PinnedItemList
│ │ │ └── index.js
│ │ ├── Footer
│ │ │ └── index.js
│ │ ├── ModeratorRoleButton
│ │ │ └── index.js
│ │ ├── AccountBlockButton
│ │ │ └── index.js
│ │ ├── PrivateRoute
│ │ │ └── index.js
│ │ ├── Modal
│ │ │ └── index.js
│ │ ├── ContentBlockButton
│ │ │ └── index.js
│ │ ├── PinnedItemListItem
│ │ │ └── index.js
│ │ ├── DeleteAccountModal
│ │ │ └── index.js
│ │ ├── SearchForm
│ │ │ └── index.js
│ │ ├── NavBar
│ │ │ └── index.js
│ │ ├── NewReplyModal
│ │ │ └── index.js
│ │ ├── CreateProfileForm
│ │ │ └── index.js
│ │ ├── DeleteContentModal
│ │ │ └── index.js
│ │ ├── ProfileListItem
│ │ │ └── index.js
│ │ ├── SingleContent
│ │ │ └── index.js
│ │ ├── ProfileHeader
│ │ │ └── index.js
│ │ ├── ContentListItem
│ │ │ └── index.js
│ │ ├── ProfileTabs
│ │ │ └── index.js
│ │ ├── CreateContentForm
│ │ │ └── index.js
│ │ └── EditProfileForm
│ │ │ └── index.js
│ ├── styles
│ │ ├── theme.js
│ │ └── global.js
│ ├── graphql
│ │ ├── typePolicies.js
│ │ ├── fragments.js
│ │ ├── apollo.js
│ │ ├── mutations.js
│ │ └── queries.js
│ ├── index.js
│ ├── lib
│ │ ├── displayDatetime.js
│ │ └── updateQueries.js
│ ├── layouts
│ │ └── MainLayout
│ │ │ └── index.js
│ └── context
│ │ └── AuthContext.js
├── .env.example
├── nginx
│ └── default.conf
├── Dockerfile
└── package.json
├── .env.example
├── server
├── .env.production.local.example
├── src
│ ├── lib
│ │ ├── getPermissions.js
│ │ ├── customScalars.js
│ │ ├── getToken.js
│ │ ├── getProjectionFields.js
│ │ ├── Queue.js
│ │ └── handleUploads.js
│ ├── config
│ │ ├── cloudinary.js
│ │ ├── auth0.js
│ │ ├── redis.js
│ │ ├── mongoose.js
│ │ ├── app.js
│ │ └── apollo.js
│ ├── services
│ │ ├── accounts
│ │ │ ├── queues.js
│ │ │ ├── index.js
│ │ │ ├── permissions.js
│ │ │ ├── resolvers.js
│ │ │ ├── typeDefs.js
│ │ │ └── datasources
│ │ │ │ └── AccountsDataSource.js
│ │ ├── content
│ │ │ ├── queues.js
│ │ │ ├── index.js
│ │ │ ├── permissions.js
│ │ │ ├── resolvers.js
│ │ │ └── typeDefs.js
│ │ └── profiles
│ │ │ ├── queues.js
│ │ │ ├── index.js
│ │ │ ├── permissions.js
│ │ │ ├── resolvers.js
│ │ │ └── typeDefs.js
│ ├── index.js
│ ├── scripts
│ │ ├── authenticateUser.js
│ │ ├── testQueue.js
│ │ └── auth0-deploy
│ │ │ ├── export.js
│ │ │ ├── import.js
│ │ │ ├── rules
│ │ │ └── Add authorization details to token.js
│ │ │ └── tenant.yaml
│ └── models
│ │ ├── Post.js
│ │ ├── Reply.js
│ │ └── Profile.js
├── process.yml
├── .env.example
├── .env.nodocker.example
├── Dockerfile
└── package.json
├── .dockerignore
├── docker-compose.dev.yml
├── .gitignore
├── docker-compose.prod.yml
├── docker-compose.yml
├── nginx
└── default.template
├── scripts
└── init-letsencrypt.sh
└── README.md
/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | DOMAIN=
2 |
3 | MONGO_INITDB_ROOT_USERNAME=chirpsdev
4 | MONGO_INITDB_ROOT_PASSWORD=
5 |
6 | REDIS_PASSWORD=
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/8bitpress/advanced-graphql-source-code/HEAD/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/8bitpress/advanced-graphql-source-code/HEAD/client/public/logo192.png
--------------------------------------------------------------------------------
/client/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/8bitpress/advanced-graphql-source-code/HEAD/client/public/logo512.png
--------------------------------------------------------------------------------
/client/src/routes/history.js:
--------------------------------------------------------------------------------
1 | import { createBrowserHistory } from "history";
2 |
3 | export default createBrowserHistory();
4 |
--------------------------------------------------------------------------------
/client/public/favicon_new.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/8bitpress/advanced-graphql-source-code/HEAD/client/public/favicon_new.ico
--------------------------------------------------------------------------------
/client/src/pages/Login/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import Loader from "../../components/Loader";
4 |
5 | const Login = () => ;
6 |
7 | export default Login;
8 |
--------------------------------------------------------------------------------
/server/.env.production.local.example:
--------------------------------------------------------------------------------
1 | AUTH0_DOMAIN=
2 |
3 | AUTH0_CLIENT_ID_DEPLOY=
4 | AUTH0_CLIENT_SECRET_DEPLOY=
5 |
6 | GITHUB_CLIENT_ID_AUTH0=
7 | GITHUB_CLIENT_SECRET_AUTH0=
8 |
9 | NODE_ENV=production
--------------------------------------------------------------------------------
/server/src/lib/getPermissions.js:
--------------------------------------------------------------------------------
1 | export default function(user) {
2 | if (user && user["https://devchirps.com/user_authorization"]) {
3 | return user["https://devchirps.com/user_authorization"].permissions;
4 | }
5 | return [];
6 | }
7 |
--------------------------------------------------------------------------------
/client/.env.example:
--------------------------------------------------------------------------------
1 | REACT_APP_AUTH0_CLIENT_ID=
2 | REACT_APP_AUTH0_CALLBACK_URL=http://localhost:3000/login
3 | REACT_APP_AUTH0_DOMAIN=
4 | REACT_APP_AUTH0_LOGOUT_URL=http://localhost:3000
5 |
6 | REACT_APP_GRAPHQL_ENDPOINT=http://localhost:4000/graphql
--------------------------------------------------------------------------------
/client/src/components/Container/index.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const Container = styled.div`
4 | margin: 0 auto;
5 | max-width: 840px;
6 | padding: 0 1rem;
7 | width: 100%;
8 | `;
9 |
10 | export default Container;
11 |
--------------------------------------------------------------------------------
/client/src/components/AccentButton/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import StyledAccentButton from "./styles";
4 |
5 | const AccentButton = props => (
6 |
7 | );
8 |
9 | export default AccentButton;
10 |
--------------------------------------------------------------------------------
/client/src/components/LoadMoreButton/index.js:
--------------------------------------------------------------------------------
1 | import { Button } from "grommet";
2 | import React from "react";
3 |
4 | const LoadMoreButton = props => (
5 |
6 | );
7 |
8 | export default LoadMoreButton;
9 |
--------------------------------------------------------------------------------
/client/src/components/NotAvailableMessage/styles.js:
--------------------------------------------------------------------------------
1 | import { Text } from "grommet";
2 | import styled from "styled-components";
3 |
4 | const StyledText = styled(Text)`
5 | background: #ededed;
6 | padding: 0.25rem 0.5rem;
7 | `;
8 |
9 | export default StyledText;
10 |
--------------------------------------------------------------------------------
/client/src/components/HoverBox/index.js:
--------------------------------------------------------------------------------
1 | import { Box } from "grommet";
2 | import styled from "styled-components";
3 |
4 | const HoverBox = styled(Box)`
5 | &:hover {
6 | background-color: #f8f8f8;
7 | cursor: pointer;
8 | }
9 | `;
10 |
11 | export default HoverBox;
12 |
--------------------------------------------------------------------------------
/client/nginx/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 3000;
3 |
4 | root /usr/share/nginx/html;
5 | index index.html index.htm;
6 |
7 | location ~ ^.+\..+$ {
8 | try_files $uri =404;
9 | }
10 |
11 | location / {
12 | try_files $uri $uri/ /index.html;
13 | }
14 | }
--------------------------------------------------------------------------------
/server/src/config/cloudinary.js:
--------------------------------------------------------------------------------
1 | import { v2 as cloudinary } from "cloudinary";
2 |
3 | cloudinary.config({
4 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
5 | api_key: process.env.CLOUDINARY_API_KEY,
6 | api_secret: process.env.CLOUDINARY_API_SECRET
7 | });
8 |
9 | export default cloudinary;
10 |
--------------------------------------------------------------------------------
/client/src/components/AccentButton/styles.js:
--------------------------------------------------------------------------------
1 | import { Button } from "grommet";
2 | import styled from "styled-components";
3 |
4 | const StyledAccentButton = styled(Button)`
5 | background-color: #81fced;
6 | color: #7d4cdb;
7 | font-weight: bold;
8 | `;
9 |
10 | export default StyledAccentButton;
11 |
--------------------------------------------------------------------------------
/server/src/config/auth0.js:
--------------------------------------------------------------------------------
1 | import { ManagementClient } from "auth0";
2 |
3 | const auth0 = new ManagementClient({
4 | domain: process.env.AUTH0_DOMAIN,
5 | clientId: process.env.AUTH0_CLIENT_ID_MGMT_API,
6 | clientSecret: process.env.AUTH0_CLIENT_SECRET_MGMT_API
7 | });
8 |
9 | export default auth0;
10 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # editor
2 |
3 | .vscode
4 |
5 | # docker
6 |
7 | .dockerignore
8 | docker-compose*
9 | **/Dockerfile*
10 |
11 | # logs
12 |
13 | **/npm-debug.log*
14 |
15 | ## packages
16 |
17 | **/node_modules/
18 |
19 | # git
20 |
21 | .git
22 | .gitingore
23 |
24 | # misc
25 |
26 | LICENSE
27 | README.md
28 |
--------------------------------------------------------------------------------
/client/src/styles/theme.js:
--------------------------------------------------------------------------------
1 | export default {
2 | global: {
3 | font: {
4 | family:
5 | '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
6 | size: "16px",
7 | height: "20px"
8 | }
9 | }
10 | };
11 |
--------------------------------------------------------------------------------
/server/src/services/accounts/queues.js:
--------------------------------------------------------------------------------
1 | import { redisSMQ } from "../../config/redis";
2 | import Queue from "../../lib/Queue";
3 |
4 | export async function initDeleteAccountQueue() {
5 | const deleteAccountQueue = new Queue(redisSMQ, "account_deleted");
6 | await deleteAccountQueue.createQueue();
7 | return deleteAccountQueue;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/components/RequiredLabel/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Text } from "grommet";
2 | import React from "react";
3 |
4 | const RequiredLabel = ({ children }) => (
5 |
6 | {children}
7 | *
8 |
9 | );
10 |
11 | export default RequiredLabel;
12 |
--------------------------------------------------------------------------------
/client/src/components/NotAvailableMessage/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Text } from "grommet";
2 | import React from "react";
3 |
4 | import StyledText from "./styles";
5 |
6 | const NotAvailableMessage = ({ text, ...rest }) => (
7 |
8 |
9 | {text}
10 |
11 |
12 | );
13 |
14 | export default NotAvailableMessage;
15 |
--------------------------------------------------------------------------------
/server/src/index.js:
--------------------------------------------------------------------------------
1 | import app from "./config/app";
2 | import initGateway from "./config/apollo";
3 |
4 | (async () => {
5 | const port = process.env.PORT;
6 | const server = await initGateway();
7 |
8 | server.applyMiddleware({ app, cors: false });
9 |
10 | app.listen({ port }, () =>
11 | console.log(`Server ready at http://localhost:${port}${server.graphqlPath}`)
12 | );
13 | })();
14 |
--------------------------------------------------------------------------------
/client/src/components/CharacterCountLabel/index.js:
--------------------------------------------------------------------------------
1 | import { Text } from "grommet";
2 | import React from "react";
3 |
4 | const CharacterCountLabel = ({ currentChars, label, max }) => {
5 | return (
6 | max ? "status-critical" : null}>
7 | {`${label} (${currentChars}/${max} characters)`}
8 |
9 | );
10 | };
11 |
12 | export default CharacterCountLabel;
13 |
--------------------------------------------------------------------------------
/client/src/pages/NotFound/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Heading } from "grommet";
2 | import React from "react";
3 |
4 | import MainLayout from "../../layouts/MainLayout";
5 |
6 | const NotFound = () => (
7 |
8 |
9 | Sorry! That page cannot be found.
10 |
11 |
12 | );
13 |
14 | export default NotFound;
15 |
--------------------------------------------------------------------------------
/client/src/components/RichTabTitle/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Text } from "grommet";
2 | import React from "react";
3 |
4 | const RichTabTitle = ({ icon, label }) => (
5 |
6 | {icon}
7 |
8 | {label}
9 |
10 |
11 | );
12 |
13 | export default RichTabTitle;
14 |
--------------------------------------------------------------------------------
/client/src/styles/global.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from "styled-components";
2 | import reset from "styled-reset";
3 |
4 | export default createGlobalStyle`
5 | ${reset}
6 |
7 | * {
8 | box-sizing: border-box;
9 | }
10 |
11 | body {
12 | min-height: 100vh;
13 | }
14 |
15 | a {
16 | text-decoration: none;
17 | }
18 |
19 | img {
20 | height: auto;
21 | max-width: 100%;
22 | }
23 | `;
24 |
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | mongo:
5 | ports:
6 | - 27017:27017
7 | redis:
8 | ports:
9 | - 6379:6379
10 | graphql:
11 | build:
12 | target: base
13 | ports:
14 | - 4000:4000
15 | command: pm2-dev process.yml
16 | web:
17 | build:
18 | target: base
19 | ports:
20 | - 3000:3000
21 | stdin_open: true
22 | command: npm start
23 |
--------------------------------------------------------------------------------
/server/src/scripts/authenticateUser.js:
--------------------------------------------------------------------------------
1 | // Usage (from `scripts` directory):
2 | // $ node -r dotenv/config -r esm authenticateUser.js
3 |
4 | import getToken from "../lib/getToken";
5 |
6 | (async () => {
7 | const [email, password] = process.argv.slice(2);
8 | const access_token = await getToken(email, password).catch(error => {
9 | console.log(error);
10 | });
11 | console.log(access_token);
12 | })();
13 |
--------------------------------------------------------------------------------
/server/process.yml:
--------------------------------------------------------------------------------
1 | apps:
2 | - script: src/services/accounts/index.js
3 | name: accounts
4 | node_args: "-r dotenv/config -r esm"
5 | - script: src/services/content/index.js
6 | name: content
7 | node_args: "-r dotenv/config -r esm"
8 | - script: src/services/profiles/index.js
9 | name: profiles
10 | node_args: "-r dotenv/config -r esm"
11 | - script: src/index.js
12 | name: gateway
13 | node_args: "-r dotenv/config -r esm"
14 |
--------------------------------------------------------------------------------
/client/src/components/Loader/index.js:
--------------------------------------------------------------------------------
1 | import { Refresh } from "grommet-icons";
2 | import React from "react";
3 |
4 | import StyledLoader from "./styles";
5 |
6 | const Loader = ({ centered, color, size }) => (
7 |
8 |
9 |
10 | );
11 |
12 | Loader.defaultProps = {
13 | centered: false,
14 | color: "brand",
15 | size: "large"
16 | };
17 |
18 | export default Loader;
19 |
--------------------------------------------------------------------------------
/client/src/components/ContentList/index.js:
--------------------------------------------------------------------------------
1 | import { Box } from "grommet";
2 | import React from "react";
3 |
4 | import ContentListItem from "../ContentListItem";
5 |
6 | const ContentList = ({ contentData }) => (
7 |
8 | {contentData.map(itemContentData => (
9 |
13 | ))}
14 |
15 | );
16 |
17 | export default ContentList;
18 |
--------------------------------------------------------------------------------
/client/src/components/ProfileList/index.js:
--------------------------------------------------------------------------------
1 | import { Box } from "grommet";
2 | import React from "react";
3 |
4 | import ProfileListItem from "../ProfileListItem";
5 |
6 | const ProfileList = ({ profileData }) => (
7 |
8 | {profileData.map(itemProfileData => (
9 |
13 | ))}
14 |
15 | );
16 |
17 | export default ProfileList;
18 |
--------------------------------------------------------------------------------
/server/.env.example:
--------------------------------------------------------------------------------
1 | AUTH0_AUDIENCE=http://localhost:4000/graphql
2 | AUTH0_DOMAIN=
3 | AUTH0_ISSUER=
4 |
5 | AUTH0_CLIENT_ID_DEPLOY=
6 | AUTH0_CLIENT_SECRET_DEPLOY=
7 |
8 | AUTH0_CLIENT_ID_GRAPHQL=
9 | AUTH0_CLIENT_SECRET_GRAPHQL=
10 |
11 | AUTH0_CLIENT_ID_MGMT_API=
12 | AUTH0_CLIENT_SECRET_MGMT_API=
13 |
14 | CLOUDINARY_API_KEY=
15 | CLOUDINARY_API_SECRET=
16 | CLOUDINARY_CLOUD_NAME=
17 |
18 | MONGODB_URL=mongodb://mongo:27017:devchirps-dev
19 |
20 | NODE_ENV=development
21 |
--------------------------------------------------------------------------------
/client/src/components/PinnedItemList/index.js:
--------------------------------------------------------------------------------
1 | import { Box } from "grommet";
2 | import React from "react";
3 |
4 | import PinnedItemListItem from "../PinnedItemListItem";
5 |
6 | const PinnedItemList = ({ pinnedItemsData }) => {
7 | return (
8 |
9 | {pinnedItemsData.map(pinnedItemData => (
10 |
14 | ))}
15 |
16 | );
17 | };
18 |
19 | export default PinnedItemList;
20 |
--------------------------------------------------------------------------------
/client/src/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Text } from "grommet";
2 | import React from "react";
3 |
4 | const Footer = () => (
5 |
19 | );
20 |
21 | export default Footer;
22 |
--------------------------------------------------------------------------------
/client/src/components/Loader/styles.js:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from "styled-components";
2 |
3 | const rotate = keyframes`
4 | 0% {
5 | transform: rotate(0deg);
6 | }
7 | 50% {
8 | transform: rotate(180deg);
9 | }
10 | 100% {
11 | transform: rotate(360deg);
12 | }
13 | `;
14 |
15 | const StyledLoader = styled.div`
16 | ${prop =>
17 | prop.centered &&
18 | "left: 50%; position: absolute; top: 50%; transform: translate(-50%, -50%);"}
19 |
20 | svg {
21 | animation: ${rotate} 1.5s infinite;
22 | }
23 | `;
24 |
25 | export default StyledLoader;
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # editor
2 |
3 | .vscode
4 |
5 | # dependencies
6 |
7 | node_modules
8 |
9 | # environment
10 |
11 | .env
12 | .env.local
13 | .env.development.local
14 | .env.test.local
15 | .env.production.local
16 |
17 | # databases
18 |
19 | data
20 |
21 | # config
22 |
23 | certbot
24 | nginx/default.conf
25 |
26 | # client
27 |
28 | client/coverage
29 | cilent/build
30 |
31 | # logs
32 |
33 | npm-debug.log*
34 | yarn-debug.log*
35 | yarn-error.log*
36 |
37 | # misc
38 |
39 | .DS_Store
40 | .DS_Store?
41 | ._*
42 | .Spotlight-V100
43 | .Trashes
44 | ehthumbs.db
45 | *[Tt]humbs.db
46 | *.Trashes
47 |
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/models/Post.js:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const postSchema = new mongoose.Schema({
4 | authorProfileId: {
5 | type: mongoose.Schema.Types.ObjectId,
6 | required: true
7 | },
8 | blocked: {
9 | type: Boolean
10 | },
11 | createdAt: {
12 | type: Date,
13 | default: Date.now,
14 | required: true
15 | },
16 | media: {
17 | type: String
18 | },
19 | text: {
20 | type: String,
21 | maxlength: 256,
22 | required: true
23 | }
24 | });
25 |
26 | postSchema.index({ text: "text" });
27 |
28 | const Post = mongoose.model("Post", postSchema);
29 |
30 | export default Post;
31 |
--------------------------------------------------------------------------------
/client/src/graphql/typePolicies.js:
--------------------------------------------------------------------------------
1 | export default {
2 | Post: {
3 | fields: {
4 | replies: {
5 | keyArgs: []
6 | }
7 | }
8 | },
9 | Profile: {
10 | fields: {
11 | following: {
12 | keyArgs: []
13 | },
14 | posts: {
15 | keyArgs: []
16 | },
17 | replies: {
18 | keyArgs: []
19 | }
20 | }
21 | },
22 | Query: {
23 | fields: {
24 | posts: {
25 | keyArgs: ["filter"]
26 | },
27 | searchPosts: {
28 | keyArgs: ["query"]
29 | },
30 | searchProfiles: {
31 | keyArgs: ["query"]
32 | }
33 | }
34 | }
35 | };
36 |
--------------------------------------------------------------------------------
/server/src/config/redis.js:
--------------------------------------------------------------------------------
1 | import redis from "redis";
2 | import RedisSMQ from "rsmq";
3 |
4 | const host = process.env.REDIS_HOST_ADDRESS;
5 | const port = process.env.REDIS_PORT;
6 | const client = redis.createClient({
7 | ...(process.env.NODE_ENV === "production" && {
8 | password: process.env.REDIS_PASSWORD
9 | }),
10 | host,
11 | port
12 | });
13 |
14 | client.on("connect", () => {
15 | console.log(`Redis connection ready at http://${host}:${port}/`);
16 | });
17 |
18 | client.on("error", error => {
19 | console.log("Redis connection error:", error);
20 | });
21 |
22 | export const redisSMQ = new RedisSMQ({ client, ns: "rsmq" });
23 |
24 | export default redis;
25 |
--------------------------------------------------------------------------------
/server/src/scripts/testQueue.js:
--------------------------------------------------------------------------------
1 | // Usage (from `server` directory):
2 | // $ node -r dotenv/config -r esm src/scripts/testQueue.js
3 |
4 | import { redisSMQ } from "../config/redis";
5 | import Queue from "../lib/Queue";
6 |
7 | (async () => {
8 | const testQueue = new Queue(redisSMQ, "test_queue");
9 |
10 | await testQueue.createQueue();
11 |
12 | const sentMessage = await testQueue.sendMessage("Test message");
13 | console.log("Sent message:", sentMessage);
14 |
15 | await testQueue.listen({ interval: 1000, maxReceivedCount: 5 }, payload => {
16 | console.log("Message received:", payload);
17 | // throw new Error("Something went wrong");
18 | });
19 |
20 | // await testQueue.deleteQueue();
21 | })();
22 |
--------------------------------------------------------------------------------
/server/.env.nodocker.example:
--------------------------------------------------------------------------------
1 | ACCOUNTS_SERVICE_PORT=4001
2 | ACCOUNTS_SERVICE_URL=http://localhost:4001
3 |
4 | AUTH0_AUDIENCE=
5 | AUTH0_DOMAIN=
6 | AUTH0_ISSUER=
7 |
8 | AUTH0_CLIENT_ID_DEPLOY=
9 | AUTH0_CLIENT_SECRET_DEPLOY=
10 |
11 | AUTH0_CLIENT_ID_GRAPHQL=
12 | AUTH0_CLIENT_SECRET_GRAPHQL=
13 |
14 | AUTH0_CLIENT_ID_MGMT_API=
15 | AUTH0_CLIENT_SECRET_MGMT_API=
16 |
17 | CLOUDINARY_API_KEY=
18 | CLOUDINARY_API_SECRET=
19 | CLOUDINARY_CLOUD_NAME=
20 |
21 | CONTENT_SERVICE_PORT=4003
22 | CONTENT_SERVICE_URL=http://localhost:4003
23 |
24 | MONGODB_URL=mongodb://127.0.0.1:27017/devchirps-dev
25 |
26 | NODE_ENV=development
27 | PORT=4000
28 |
29 | PROFILES_SERVICE_PORT=4002
30 | PROFILES_SERVICE_URL=http://localhost:4002
31 |
32 | REDIS_HOST_ADDRESS=127.0.0.1
33 | REDIS_PORT=6379
--------------------------------------------------------------------------------
/server/src/models/Reply.js:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const replySchema = new mongoose.Schema({
4 | authorProfileId: {
5 | type: mongoose.Schema.Types.ObjectId,
6 | required: true
7 | },
8 | blocked: {
9 | type: Boolean
10 | },
11 | createdAt: {
12 | type: Date,
13 | default: Date.now,
14 | required: true
15 | },
16 | media: {
17 | type: String
18 | },
19 | postAuthorProfileId: {
20 | type: mongoose.Schema.Types.ObjectId,
21 | required: true
22 | },
23 | postId: {
24 | type: mongoose.Schema.Types.ObjectId,
25 | require: true
26 | },
27 | text: {
28 | type: String,
29 | maxlength: 256,
30 | required: true
31 | }
32 | });
33 |
34 | const Reply = mongoose.model("Reply", replySchema);
35 |
36 | export default Reply;
37 |
--------------------------------------------------------------------------------
/server/src/scripts/auth0-deploy/export.js:
--------------------------------------------------------------------------------
1 | // Usage (from `server` directory):
2 | // $ node -r dotenv/config -r esm src/scripts/auth0-deploy/export.js
3 |
4 | import { dump } from "auth0-deploy-cli";
5 |
6 | (() => {
7 | const config = {
8 | AUTH0_API_MAX_RETRIES: 10,
9 | AUTH0_CLIENT_ID: process.env.A0DEPLOY_CLIENT_ID_DEV,
10 | AUTH0_CLIENT_SECRET: process.env.A0DEPLOY_CLIENT_SECRET_DEV,
11 | AUTH0_DOMAIN: process.env.AUTH0_DOMAIN,
12 | AUTH0_EXPORT_IDENTIFIERS: false
13 | };
14 |
15 | dump({
16 | config,
17 | format: "yaml",
18 | output_folder: "src/scripts/auth0-deploy"
19 | })
20 | .then(() => {
21 | console.log("Tenant export was successful.");
22 | })
23 | .catch(error => {
24 | console.log("Error exporting tenant:", error);
25 | });
26 | })();
27 |
--------------------------------------------------------------------------------
/server/src/config/mongoose.js:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | export default function() {
4 | const connectionUrl = process.env.MONGODB_URL;
5 |
6 | mongoose.connect(connectionUrl, {
7 | ...(process.env.NODE_ENV === "production" && {
8 | authSource: "admin",
9 | pass: process.env.MONGO_INITDB_ROOT_PASSWORD,
10 | user: process.env.MONGO_INITDB_ROOT_USERNAME
11 | }),
12 | useNewUrlParser: true,
13 | useFindAndModify: false,
14 | useCreateIndex: true,
15 | useUnifiedTopology: true
16 | });
17 |
18 | mongoose.connection.on("connected", () => {
19 | console.log(`Mongoose default connection ready at ${connectionUrl}`);
20 | });
21 |
22 | mongoose.connection.on("error", error => {
23 | console.log("Mongoose default connection error:", error);
24 | });
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import { Grommet } from "grommet";
2 | import { Router } from "react-router-dom";
3 | import React from "react";
4 | import ReactDOM from "react-dom";
5 |
6 | import { AuthProvider } from "./context/AuthContext";
7 | import ApolloProviderWithAuth from "./graphql/apollo";
8 | import GlobalStyle from "./styles/global";
9 | import history from "./routes/history";
10 | import Routes from "./routes";
11 | import theme from "./styles/theme";
12 |
13 | const App = () => (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 |
26 | ReactDOM.render(, document.getElementById("root"));
27 |
--------------------------------------------------------------------------------
/client/src/lib/displayDatetime.js:
--------------------------------------------------------------------------------
1 | import moment from "moment";
2 |
3 | export function displayFullDatetime(dateString) {
4 | const datetime = new Date(dateString);
5 | return moment(datetime).format("MMMM D, YYYY [at] h:mm a");
6 | }
7 |
8 | export function displayRelativeDateOrTime(dateString) {
9 | const today = new Date();
10 | const previousDate = new Date(dateString);
11 |
12 | if (
13 | previousDate.getDate() === today.getDate() &&
14 | previousDate.getMonth() === today.getMonth() &&
15 | previousDate.getFullYear() === today.getFullYear()
16 | ) {
17 | return moment(previousDate).format("h:mm a");
18 | } else if (previousDate.getFullYear() === today.getFullYear()) {
19 | return moment(previousDate).format("MMMM D");
20 | } else {
21 | return moment(previousDate).format("MMMM D, YYYY");
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/layouts/MainLayout/index.js:
--------------------------------------------------------------------------------
1 | import { Box } from "grommet";
2 | import React from "react";
3 |
4 | import Container from "../../components/Container";
5 | import NavBar from "../../components/NavBar";
6 | import Footer from "../../components/Footer";
7 |
8 | const MainLayout = ({ centered, children }) => (
9 |
15 |
16 |
22 | {children}
23 |
24 |
25 |
26 | );
27 |
28 | MainLayout.defaultProps = {
29 | centered: false
30 | };
31 |
32 | export default MainLayout;
33 |
--------------------------------------------------------------------------------
/server/src/services/content/queues.js:
--------------------------------------------------------------------------------
1 | import {
2 | deleteUserUploads,
3 | deleteUserUploadsDir
4 | } from "../../lib/handleUploads";
5 | import { redisSMQ } from "../../config/redis";
6 | import Queue from "../../lib/Queue.js";
7 | import Post from "../../models/Post";
8 | import Reply from "../../models/Reply";
9 |
10 | export async function initDeleteProfileQueue() {
11 | const deleteProfileQueue = new Queue(redisSMQ, "profile_deleted");
12 | await deleteProfileQueue.createQueue();
13 | return deleteProfileQueue;
14 | }
15 |
16 | export async function onDeleteProfile(payload) {
17 | const { authorProfileId } = JSON.parse(payload.message);
18 | await Post.deleteMany({ authorProfileId }).exec();
19 | await Reply.deleteMany({ authorProfileId }).exec();
20 | await deleteUserUploads(authorProfileId);
21 | await deleteUserUploadsDir(authorProfileId);
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/config/app.js:
--------------------------------------------------------------------------------
1 | import cors from "cors";
2 | import express from "express";
3 | import jwt from "express-jwt";
4 | import jwksClient from "jwks-rsa";
5 |
6 | const app = express();
7 |
8 | const jwtCheck = jwt({
9 | secret: jwksClient.expressJwtSecret({
10 | cache: true,
11 | rateLimit: true,
12 | jwksRequestsPerMinute: 5,
13 | jwksUri: `${process.env.AUTH0_ISSUER}.well-known/jwks.json`
14 | }),
15 | audience: process.env.AUTH0_AUDIENCE,
16 | issuer: process.env.AUTH0_ISSUER,
17 | algorithms: ["RS256"],
18 | credentialsRequired: false
19 | });
20 |
21 | app.use(jwtCheck, (err, req, res, next) => {
22 | if (err.code === "invalid_token") {
23 | return next();
24 | }
25 | return next(err);
26 | });
27 |
28 | if (process.env.NODE_ENV === "development") {
29 | app.use(cors({ origin: "http://localhost:3000" }));
30 | }
31 |
32 | export default app;
33 |
--------------------------------------------------------------------------------
/server/src/lib/customScalars.js:
--------------------------------------------------------------------------------
1 | import { ApolloError } from "apollo-server";
2 | import { GraphQLScalarType } from "graphql";
3 | import { isISO8601 } from "validator";
4 |
5 | export const DateTimeResolver = new GraphQLScalarType({
6 | name: "DateTime",
7 | description: "An ISO 8601-encoded UTC date string.",
8 | parseValue: value => {
9 | if (isISO8601(value)) {
10 | return value;
11 | }
12 | throw new ApolloError("DateTime must be a valid ISO 8601 Date string");
13 | },
14 | serialize: value => {
15 | if (typeof value !== "string") {
16 | value = value.toISOString();
17 | }
18 |
19 | if (isISO8601(value)) {
20 | return value;
21 | }
22 | throw new ApolloError("DateTime must be a valid ISO 8601 Date string");
23 | },
24 | parseLiteral: ast => {
25 | if (isISO8601(ast.value)) {
26 | return ast.value;
27 | }
28 | throw new ApolloError("DateTime must be a valid ISO 8601 Date string");
29 | }
30 | });
31 |
--------------------------------------------------------------------------------
/server/src/services/profiles/queues.js:
--------------------------------------------------------------------------------
1 | import { redisSMQ } from "../../config/redis";
2 | import Profile from "../../models/Profile";
3 | import Queue from "../../lib/Queue";
4 |
5 | export async function initDeleteAccountQueue() {
6 | const deleteAccountQueue = new Queue(redisSMQ, "account_deleted");
7 | await deleteAccountQueue.createQueue();
8 | return deleteAccountQueue;
9 | }
10 |
11 | export async function initDeleteProfileQueue() {
12 | const deleteProfileQueue = new Queue(redisSMQ, "profile_deleted");
13 | await deleteProfileQueue.createQueue();
14 | return deleteProfileQueue;
15 | }
16 |
17 | export async function onDeleteAccount(payload, deleteProfileQueue) {
18 | const { accountId } = JSON.parse(payload.message);
19 | const { _id } = await Profile.findOneAndDelete({ accountId }).exec();
20 | await Profile.updateMany({ $pull: { following: _id } }).exec();
21 |
22 | if (_id) {
23 | deleteProfileQueue.sendMessage(JSON.stringify({ authorProfileId: _id }));
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/models/Profile.js:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const profileSchema = new mongoose.Schema({
4 | accountId: {
5 | type: String,
6 | required: true
7 | },
8 | avatar: {
9 | type: String
10 | },
11 | description: {
12 | type: String,
13 | maxlength: 256
14 | },
15 | following: [mongoose.Schema.Types.ObjectId],
16 | fullName: {
17 | type: String,
18 | trim: true
19 | },
20 | githubUrl: {
21 | type: String
22 | },
23 | pinnedItems: [
24 | {
25 | githubId: { type: String, required: true },
26 | description: { type: String },
27 | name: { type: String, required: true },
28 | primaryLanguage: { type: String },
29 | url: { type: String, required: true }
30 | }
31 | ],
32 | username: {
33 | type: String,
34 | required: true,
35 | trim: true,
36 | unique: true
37 | }
38 | });
39 |
40 | profileSchema.index({ fullName: "text", username: "text" });
41 |
42 | const Profile = mongoose.model("Profile", profileSchema);
43 |
44 | export default Profile;
45 |
--------------------------------------------------------------------------------
/client/src/components/ModeratorRoleButton/index.js:
--------------------------------------------------------------------------------
1 | import { Button } from "grommet";
2 | import { useMutation } from "@apollo/client";
3 | import { UserAdmin } from "grommet-icons";
4 | import React from "react";
5 |
6 | import { CHANGE_ACCOUNT_MODERATOR_ROLE } from "../../graphql/mutations";
7 |
8 | const ModeratorRoleButton = ({ accountId, iconSize, isModerator }) => {
9 | const [changeAccountModeratorRole, { loading }] = useMutation(
10 | CHANGE_ACCOUNT_MODERATOR_ROLE
11 | );
12 |
13 | return (
14 |
19 | }
20 | onClick={() => {
21 | changeAccountModeratorRole({
22 | variables: { where: { id: accountId } }
23 | }).catch(err => {
24 | console.log(err);
25 | });
26 | }}
27 | />
28 | );
29 | };
30 |
31 | ModeratorRoleButton.defaultProps = {
32 | iconSize: "small"
33 | };
34 |
35 | export default ModeratorRoleButton;
36 |
--------------------------------------------------------------------------------
/server/src/scripts/auth0-deploy/import.js:
--------------------------------------------------------------------------------
1 | // Usage (from `server` directory):
2 | // $ node -r dotenv/config -r esm src/scripts/auth0-deploy/import.js dotenv_config_path=.env.production
3 |
4 | import { deploy } from "auth0-deploy-cli";
5 |
6 | (() => {
7 | const config = {
8 | AUTH0_ALLOW_DELETE: false,
9 | AUTH0_API_MAX_RETRIES: 10,
10 | AUTH0_CLIENT_ID: process.env.AUTH0_CLIENT_ID_DEPLOY,
11 | AUTH0_CLIENT_SECRET: process.env.AUTH0_CLIENT_SECRET_DEPLOY,
12 | AUTH0_DOMAIN: process.env.AUTH0_DOMAIN,
13 | AUTH0_EXCLUDED_CLIENTS: ["auth0-deploy-cli-extension"],
14 | AUTH0_KEYWORD_REPLACE_MAPPINGS: {
15 | GH_CLIENT_ID: process.env.GITHUB_CLIENT_ID_AUTH0,
16 | GH_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET_AUTH0
17 | }
18 | };
19 |
20 | deploy({
21 | config,
22 | env: false,
23 | input_file: "src/scripts/auth0-deploy/tenant.yaml"
24 | })
25 | .then(() => {
26 | console.log("Tenant import was successful.");
27 | })
28 | .catch(error => {
29 | console.log("Error importing tenant:", error);
30 | });
31 | })();
32 |
--------------------------------------------------------------------------------
/client/src/components/AccountBlockButton/index.js:
--------------------------------------------------------------------------------
1 | import { Button } from "grommet";
2 | import { Halt } from "grommet-icons";
3 | import { useMutation } from "@apollo/client";
4 | import React from "react";
5 |
6 | import { CHANGE_ACCOUNT_BLOCKED_STATUS } from "../../graphql/mutations";
7 |
8 | const AccountBlockButton = ({ accountId, iconSize, isBlocked }) => {
9 | const [changeAccountBlockedStatus, { loading }] = useMutation(
10 | CHANGE_ACCOUNT_BLOCKED_STATUS
11 | );
12 |
13 | return (
14 |
22 | }
23 | onClick={() => {
24 | changeAccountBlockedStatus({
25 | variables: { where: { id: accountId } }
26 | }).catch(err => {
27 | console.log(err);
28 | });
29 | }}
30 | />
31 | );
32 | };
33 |
34 | AccountBlockButton.defaultProps = {
35 | iconSize: "small"
36 | };
37 |
38 | export default AccountBlockButton;
39 |
--------------------------------------------------------------------------------
/server/src/lib/getToken.js:
--------------------------------------------------------------------------------
1 | import request from "request";
2 | import util from "util";
3 |
4 | const requestPromise = util.promisify(request);
5 |
6 | export default async function(username, password) {
7 | const options = {
8 | method: "POST",
9 | url: `https://${process.env.AUTH0_DOMAIN}/oauth/token`,
10 | headers: { "content-type": "application/x-www-form-urlencoded" },
11 | form: {
12 | audience: process.env.AUTH0_AUDIENCE,
13 | client_id: process.env.AUTH0_CLIENT_ID_GRAPHQL,
14 | client_secret: process.env.AUTH0_CLIENT_SECRET_GRAPHQL,
15 | grant_type: "http://auth0.com/oauth/grant-type/password-realm",
16 | password,
17 | realm: "Username-Password-Authentication",
18 | scope: "openid",
19 | username
20 | }
21 | };
22 |
23 | const response = await requestPromise(options).catch(error => {
24 | throw new Error(error);
25 | });
26 | const body = JSON.parse(response.body);
27 | const { access_token } = body;
28 |
29 | if (!access_token) {
30 | throw new Error(body.error_description || "Cannot retrieve access token.");
31 | }
32 |
33 | return access_token;
34 | }
35 |
--------------------------------------------------------------------------------
/client/Dockerfile:
--------------------------------------------------------------------------------
1 | #########################################################
2 | # BASE
3 | #########################################################
4 |
5 | FROM node:12-alpine AS base
6 |
7 | # Create app directory
8 | WORKDIR /usr/src/app
9 |
10 | # Copy the package.json and package-lock.json files
11 | COPY package*.json ./
12 |
13 | # Install dependencies
14 | RUN npm install --no-optional && npm cache clean --force
15 |
16 | # Copy all the files
17 | COPY . .
18 |
19 | #########################################################
20 | # BUILDER
21 | #########################################################
22 |
23 | FROM base AS builder
24 |
25 | # Create the built files
26 | RUN npm run build
27 |
28 | #########################################################
29 | # PRODUCTION
30 | #########################################################
31 |
32 | FROM nginx:1.17-alpine AS production
33 |
34 | # Expose port 3000 internally
35 | EXPOSE 3000
36 |
37 | # Copy over the nginx config
38 | COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
39 |
40 | # Copy over the previously built files
41 | COPY --from=builder /usr/src/app/build /usr/share/nginx/html
42 |
--------------------------------------------------------------------------------
/server/src/scripts/auth0-deploy/rules/Add authorization details to token.js:
--------------------------------------------------------------------------------
1 | function (user, context, callback) {
2 | if (
3 | user.app_metadata &&
4 | user.app_metadata.roles &&
5 | user.app_metadata.permissions
6 | ) {
7 | context.accessToken["https://devchirps.com/user_authorization"] = {
8 | groups: user.app_metadata.groups,
9 | roles: user.app_metadata.roles,
10 | permissions: user.app_metadata.permissions
11 | };
12 | callback(null, user, context);
13 | } else {
14 | user.app_metadata = {
15 | groups: [],
16 | roles: ["author"],
17 | permissions: [
18 | "read:own_account",
19 | "edit:own_account",
20 | "read:any_profile",
21 | "edit:own_profile",
22 | "read:any_content",
23 | "edit:own_content",
24 | "upload:own_media"
25 | ]
26 | };
27 |
28 | auth0.users
29 | .updateAppMetadata(user.user_id, user.app_metadata)
30 | .then(function() {
31 | context.accessToken["https://devchirps.com/user_authorization"] = user.app_metadata;
32 | callback(null, user, context);
33 | })
34 | .catch(function(err) {
35 | callback(err);
36 | });
37 | }
38 | }
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | #########################################################
2 | # BASE
3 | #########################################################
4 |
5 | FROM node:12-alpine AS base
6 |
7 | # Get and set the environment variables
8 | ENV NPM_CONFIG_PREFIX=/home/node/.npm-global
9 | ENV PATH=$PATH:/home/node/.npm-global/bin
10 |
11 | # Make directory for the application, owned by the node user
12 | RUN mkdir -p /home/node/app/node_modules && \
13 | chown -R node:node /home/node/app
14 |
15 | # Change into the app directory
16 | WORKDIR /home/node/app
17 |
18 | # Switch to the node user
19 | USER node
20 |
21 | # Copy the package.json and package-lock.json files
22 | COPY package*.json ./
23 |
24 | # Install dependencies
25 | RUN npm install --no-optional pm2 -g && \
26 | npm install --no-optional && npm cache clean --force
27 |
28 | # Copy all the files and make the node user the owner
29 | COPY --chown=node:node . .
30 |
31 | #########################################################
32 | # PRODUCTION
33 | #########################################################
34 |
35 | FROM base AS production
36 |
37 | # Remove unneeded development dependencies
38 | RUN npm prune --production
39 |
40 | # Expose port 4000 internally
41 | EXPOSE 4000
42 |
--------------------------------------------------------------------------------
/client/src/pages/Index/index.js:
--------------------------------------------------------------------------------
1 | import { Box } from "grommet";
2 | import { ChatOption } from "grommet-icons";
3 | import { Redirect } from "react-router-dom";
4 | import React from "react";
5 |
6 | import { useAuth } from "../../context/AuthContext";
7 | import AccentButton from "../../components/AccentButton";
8 | import Loader from "../../components/Loader";
9 | import MainLayout from "../../layouts/MainLayout";
10 |
11 | const Index = () => {
12 | const { checkingSession, isAuthenticated, login, viewerQuery } = useAuth();
13 | let viewer;
14 |
15 | if (viewerQuery && viewerQuery.data) {
16 | viewer = viewerQuery.data.viewer;
17 | }
18 |
19 | if (checkingSession) {
20 | return ;
21 | } else if (isAuthenticated && viewer) {
22 | return ;
23 | }
24 |
25 | return (
26 |
27 |
28 |
29 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default Index;
42 |
--------------------------------------------------------------------------------
/server/src/services/accounts/index.js:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from "apollo-server";
2 | import { applyMiddleware } from "graphql-middleware";
3 | import { buildFederatedSchema } from "@apollo/federation";
4 |
5 | import { initDeleteAccountQueue } from "./queues";
6 | import AccountsDataSource from "./datasources/AccountsDataSource";
7 | import auth0 from "../../config/auth0";
8 | import permissions from "./permissions";
9 | import resolvers from "./resolvers";
10 | import typeDefs from "./typeDefs";
11 |
12 | (async () => {
13 | const port = process.env.ACCOUNTS_SERVICE_PORT;
14 | const deleteAccountQueue = await initDeleteAccountQueue();
15 |
16 | const schema = applyMiddleware(
17 | buildFederatedSchema([{ typeDefs, resolvers }]),
18 | permissions
19 | );
20 |
21 | const server = new ApolloServer({
22 | schema,
23 | context: ({ req }) => {
24 | const user = req.headers.user ? JSON.parse(req.headers.user) : null;
25 | return { user, queues: { deleteAccountQueue } };
26 | },
27 | dataSources: () => {
28 | return {
29 | accountsAPI: new AccountsDataSource({ auth0 })
30 | };
31 | }
32 | });
33 |
34 | const { url } = await server.listen({ port });
35 | console.log(`Accounts service ready at ${url}`);
36 | })();
37 |
--------------------------------------------------------------------------------
/client/src/components/PrivateRoute/index.js:
--------------------------------------------------------------------------------
1 | import { Route, Redirect } from "react-router-dom";
2 | import React from "react";
3 |
4 | import { useAuth } from "../../context/AuthContext";
5 | import Loader from "../../components/Loader";
6 |
7 | const PrivateRoute = ({ component: Component, render, ...rest }) => {
8 | const { checkingSession, isAuthenticated, viewerQuery } = useAuth();
9 |
10 | const renderRoute = props => {
11 | let content = null;
12 | let viewer;
13 |
14 | if (viewerQuery && viewerQuery.data) {
15 | viewer = viewerQuery.data.viewer;
16 | }
17 |
18 | if (checkingSession) {
19 | content = ;
20 | } else if (
21 | isAuthenticated &&
22 | props.location.pathname !== "/settings/profile" &&
23 | viewer &&
24 | !viewer.profile
25 | ) {
26 | content = ;
27 | } else if (isAuthenticated && render && viewer) {
28 | content = render(props);
29 | } else if (isAuthenticated && viewer) {
30 | content = ;
31 | } else if (!viewerQuery || !viewer) {
32 | content = ;
33 | }
34 |
35 | return content;
36 | };
37 |
38 | return ;
39 | };
40 |
41 | export default PrivateRoute;
42 |
--------------------------------------------------------------------------------
/client/src/components/Modal/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Heading, Layer } from "grommet";
2 | import { Close } from "grommet-icons";
3 | import React from "react";
4 |
5 | const Modal = ({ children, handleClose, isOpen, title, width }) => (
6 |
7 | {isOpen && (
8 |
9 |
21 |
22 | {title}
23 |
24 | {handleClose && (
25 |
30 | )}
31 |
32 |
33 | {children}
34 |
35 |
36 | )}
37 |
38 | );
39 |
40 | Modal.defaultProps = {
41 | handleClose: null,
42 | isOpen: false,
43 | title: null,
44 | width: "medium"
45 | };
46 |
47 | export default Modal;
48 |
--------------------------------------------------------------------------------
/docker-compose.prod.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | mongo:
5 | environment:
6 | - MONGO_INITDB_ROOT_USERNAME
7 | - MONGO_INITDB_ROOT_PASSWORD
8 | redis:
9 | environment:
10 | - REDIS_PASSWORD
11 | command: sh -c 'exec redis-server --requirepass "$REDIS_PASSWORD"'
12 | graphql:
13 | env_file:
14 | - .env
15 | - ./server/.env
16 | command: pm2-runtime process.yml
17 | nginx:
18 | image: nginx:1.17-alpine
19 | container_name: nginx
20 | restart: always
21 | volumes:
22 | - ./nginx:/etc/nginx/conf.d
23 | - ./certbot/conf:/etc/letsencrypt
24 | - ./certbot/www:/var/www/certbot
25 | ports:
26 | - 80:80
27 | - 443:443
28 | environment:
29 | - DOMAIN
30 | depends_on:
31 | - graphql
32 | - web
33 | command: >
34 | /bin/sh -c "envsubst '$$DOMAIN' < /etc/nginx/conf.d/default.template > /etc/nginx/conf.d/default.conf && \
35 | while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g 'daemon off;'"
36 | certbot:
37 | image: certbot/certbot
38 | container_name: certbot
39 | restart: always
40 | volumes:
41 | - ./certbot/conf:/etc/letsencrypt
42 | - ./certbot/www:/var/www/certbot
43 | entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
44 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/client": "^3.2.7",
7 | "@auth0/auth0-spa-js": "^1.13.3",
8 | "apollo-link-error": "^1.1.13",
9 | "apollo-link-persisted-queries": "^0.2.2",
10 | "apollo-upload-client": "^12.1.0",
11 | "graphql": "^15.4.0",
12 | "grommet": "^2.15.2",
13 | "grommet-icons": "^4.5.0",
14 | "history": "^4.10.1",
15 | "immutability-helper": "^3.1.1",
16 | "moment": "^2.29.1",
17 | "password-validator": "^5.1.1",
18 | "query-string": "^6.13.7",
19 | "react": "^16.13.1",
20 | "react-dom": "^16.13.1",
21 | "react-router-dom": "^5.2.0",
22 | "react-scripts": "^3.4.1",
23 | "styled-components": "^5.2.0",
24 | "styled-reset": "^4.3.1",
25 | "validator": "^13.1.17"
26 | },
27 | "scripts": {
28 | "start": "react-scripts start",
29 | "build": "react-scripts build",
30 | "test": "react-scripts test",
31 | "eject": "react-scripts eject"
32 | },
33 | "eslintConfig": {
34 | "extends": "react-app"
35 | },
36 | "browserslist": {
37 | "production": [
38 | ">0.2%",
39 | "not dead",
40 | "not op_mini all"
41 | ],
42 | "development": [
43 | "last 1 chrome version",
44 | "last 1 firefox version",
45 | "last 1 safari version"
46 | ]
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/client/src/components/ContentBlockButton/index.js:
--------------------------------------------------------------------------------
1 | import { Button } from "grommet";
2 | import { Halt } from "grommet-icons";
3 | import { useMutation } from "@apollo/client";
4 | import React from "react";
5 |
6 | import { TOGGLE_POST_BLOCK, TOGGLE_REPLY_BLOCK } from "../../graphql/mutations";
7 |
8 | const ContentBlockButton = ({ iconSize, id, isBlocked, isReply }) => {
9 | const [togglePostBlock, { loading }] = useMutation(TOGGLE_POST_BLOCK);
10 | const [toggleReplyBlock] = useMutation(TOGGLE_REPLY_BLOCK);
11 |
12 | return (
13 |
21 | }
22 | onClick={() => {
23 | if (isReply) {
24 | toggleReplyBlock({
25 | variables: { where: { id } }
26 | }).catch(err => {
27 | console.log(err);
28 | });
29 | } else {
30 | togglePostBlock({
31 | variables: { where: { id } }
32 | }).catch(err => {
33 | console.log(err);
34 | });
35 | }
36 | }}
37 | />
38 | );
39 | };
40 |
41 | ContentBlockButton.defaultProps = {
42 | iconSize: "small",
43 | isReply: false
44 | };
45 |
46 | export default ContentBlockButton;
47 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | mongo:
5 | image: mongo:4.2.2
6 | container_name: mongo
7 | restart: always
8 | volumes:
9 | - ./data/db:/data/db
10 | environment:
11 | - MONGO_DATA_DIR=/data/db
12 | - MONGO_LOG_DIR=/dev/null
13 | redis:
14 | image: redis:5.0.7-alpine
15 | container_name: redis
16 | restart: always
17 | graphql:
18 | container_name: graphql
19 | restart: always
20 | build:
21 | context: ./server
22 | expose:
23 | - 4001
24 | - 4002
25 | - 4003
26 | volumes:
27 | - ./server:/home/node/app
28 | - /home/node/app/node_modules
29 | depends_on:
30 | - mongo
31 | - redis
32 | env_file:
33 | - ./server/.env
34 | environment:
35 | - ACCOUNTS_SERVICE_PORT=4001
36 | - ACCOUNTS_SERVICE_URL=http://localhost:4001
37 | - CONTENT_SERVICE_PORT=4003
38 | - CONTENT_SERVICE_URL=http://localhost:4003
39 | - PORT=4000
40 | - PROFILES_SERVICE_PORT=4002
41 | - PROFILES_SERVICE_URL=http://localhost:4002
42 | - REDIS_HOST_ADDRESS=redis
43 | - REDIS_PORT=6379
44 | web:
45 | container_name: web
46 | restart: always
47 | build:
48 | context: ./client
49 | volumes:
50 | - ./client:/usr/src/app
51 | - /usr/src/app/node_modules
52 | env_file:
53 | - ./client/.env
54 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "npx pm2 start process.yml --watch"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "dependencies": {
13 | "@apollo/federation": "^0.20.6",
14 | "@apollo/gateway": "^0.21.3",
15 | "@octokit/graphql": "^4.5.7",
16 | "apollo-datasource": "^0.7.2",
17 | "apollo-server": "^2.19.0",
18 | "apollo-server-cache-redis": "^1.2.2",
19 | "apollo-server-express": "^2.19.0",
20 | "auth0": "^2.30.0",
21 | "cloudinary": "^1.23.0",
22 | "cors": "^2.8.5",
23 | "dataloader": "^2.0.0",
24 | "dateformat": "^3.0.3",
25 | "dotenv": "^8.2.0",
26 | "esm": "^3.2.25",
27 | "express": "^4.17.1",
28 | "express-jwt": "^6.0.0",
29 | "graphql": "^15.4.0",
30 | "graphql-depth-limit": "^1.1.0",
31 | "graphql-middleware": "^4.0.2",
32 | "graphql-parse-resolve-info": "^4.9.0",
33 | "graphql-shield": "^7.4.1",
34 | "gravatar-url": "^3.1.0",
35 | "jwks-rsa": "^1.11.0",
36 | "mongoose": "^5.10.15",
37 | "node-fetch": "^2.6.1",
38 | "redis": "^3.0.2",
39 | "rsmq": "^0.12.2",
40 | "validator": "^13.1.17",
41 | "wait-on": "^5.2.0"
42 | },
43 | "devDependencies": {
44 | "auth0-deploy-cli": "^3.6.7",
45 | "faker": "^4.1.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/client/src/graphql/fragments.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const basicPost = gql`
4 | fragment basicPost on Post {
5 | id
6 | author {
7 | avatar
8 | fullName
9 | username
10 | }
11 | createdAt
12 | isBlocked
13 | media
14 | text
15 | }
16 | `;
17 |
18 | export const basicProfile = gql`
19 | fragment basicProfile on Profile {
20 | id
21 | avatar
22 | description
23 | fullName
24 | githubUrl
25 | username
26 | }
27 | `;
28 |
29 | export const basicReply = gql`
30 | fragment basicReply on Reply {
31 | id
32 | author {
33 | avatar
34 | fullName
35 | username
36 | }
37 | createdAt
38 | isBlocked
39 | media
40 | post {
41 | id
42 | }
43 | postAuthor {
44 | username
45 | }
46 | text
47 | }
48 | `;
49 |
50 | export const postsNextPage = gql`
51 | fragment postsNextPage on PostConnection {
52 | pageInfo {
53 | endCursor
54 | hasNextPage
55 | }
56 | }
57 | `;
58 |
59 | export const repliesNextPage = gql`
60 | fragment repliesNextPage on ReplyConnection {
61 | pageInfo {
62 | endCursor
63 | hasNextPage
64 | }
65 | }
66 | `;
67 |
68 | export const profilesNextPage = gql`
69 | fragment profilesNextPage on ProfileConnection {
70 | pageInfo {
71 | endCursor
72 | hasNextPage
73 | }
74 | }
75 | `;
76 |
--------------------------------------------------------------------------------
/client/src/pages/Reply/index.js:
--------------------------------------------------------------------------------
1 | import { Box } from "grommet";
2 | import { Route } from "react-router-dom";
3 | import { useQuery } from "@apollo/client";
4 | import React from "react";
5 |
6 | import { GET_REPLY } from "../../graphql/queries";
7 | import ContentListItem from "../../components/ContentListItem";
8 | import Loader from "../../components/Loader";
9 | import MainLayout from "../../layouts/MainLayout";
10 | import NotAvailableMessage from "../../components/NotAvailableMessage";
11 | import NotFound from "../NotFound";
12 | import SingleContent from "../../components/SingleContent";
13 |
14 | const Reply = ({ match }) => {
15 | const { data, loading } = useQuery(GET_REPLY, {
16 | variables: {
17 | id: match.params.id
18 | }
19 | });
20 |
21 | if (loading) {
22 | return (
23 |
24 |
25 |
26 |
27 |
28 | );
29 | } else if (data && data.reply) {
30 | const { reply } = data;
31 |
32 | return (
33 |
34 | {reply.post ? (
35 |
36 | ) : (
37 |
38 | )}
39 |
40 |
41 | );
42 | }
43 |
44 | return ;
45 | };
46 |
47 | export default Reply;
48 |
--------------------------------------------------------------------------------
/client/src/routes/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route, Switch } from "react-router";
3 |
4 | import AccountSettings from "../pages/Settings/Account";
5 | import Home from "../pages/Home";
6 | import Index from "../pages/Index";
7 | import Login from "../pages/Login";
8 | import NotFound from "../pages/NotFound";
9 | import Post from "../pages/Post";
10 | import PrivateRoute from "../components/PrivateRoute";
11 | import Profile from "../pages/Profile";
12 | import ProfileSettings from "../pages/Settings/Profile";
13 | import Reply from "../pages/Reply";
14 | import Search from "../pages/Search";
15 |
16 | const Routes = () => (
17 |
18 |
19 |
20 |
21 |
22 | [
26 | ,
27 |
28 | ]}
29 | />
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 |
38 | export default Routes;
39 |
--------------------------------------------------------------------------------
/server/src/lib/getProjectionFields.js:
--------------------------------------------------------------------------------
1 | import { parseResolveInfo } from "graphql-parse-resolve-info";
2 |
3 | export default function getProjectionFields(resolverInfo, modelSchema) {
4 | const parsedInfo = parseResolveInfo(resolverInfo);
5 | const returnType = Object.keys(parsedInfo.fieldsByTypeName).filter(
6 | field => field !== "_Entity"
7 | )[0];
8 | const baseType = returnType.replace("Connection", "");
9 | const modelSchemaFields = Object.keys(modelSchema.obj);
10 | const linkedFields = ["account", "author", "post", "postAuthor"];
11 | let queryFields;
12 |
13 | if (parsedInfo.fieldsByTypeName[returnType].hasOwnProperty("edges")) {
14 | const nodeFields =
15 | parsedInfo.fieldsByTypeName[returnType].edges.fieldsByTypeName[
16 | `${baseType}Edge`
17 | ].node.fieldsByTypeName[baseType];
18 | queryFields = Object.keys(nodeFields);
19 | } else {
20 | queryFields = Object.keys(parsedInfo.fieldsByTypeName[returnType]);
21 | }
22 |
23 | linkedFields.forEach(field => {
24 | const fieldIndex = queryFields.indexOf(field);
25 |
26 | if (fieldIndex === -1) {
27 | return;
28 | } else if (field === "author" || field === "postAuthor") {
29 | queryFields[fieldIndex] = `${field}ProfileId`;
30 | } else {
31 | queryFields[fieldIndex] = `${field}Id`;
32 | }
33 | });
34 |
35 | const trimmedQueryFields = queryFields.filter(field =>
36 | modelSchemaFields.includes(field)
37 | );
38 | trimmedQueryFields.push("_id");
39 |
40 | return trimmedQueryFields.join(" ");
41 | }
42 |
--------------------------------------------------------------------------------
/server/src/services/content/index.js:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from "apollo-server";
2 | import { applyMiddleware } from "graphql-middleware";
3 | import { buildFederatedSchema } from "@apollo/federation";
4 |
5 | import { initDeleteProfileQueue, onDeleteProfile } from "./queues";
6 | import ContentDataSource from "./datasources/ContentDataSource";
7 | import initMongoose from "../../config/mongoose";
8 | import permissions from "./permissions";
9 | import Post from "../../models/Post";
10 | import Profile from "../../models/Profile";
11 | import Reply from "../../models/Reply";
12 | import resolvers from "./resolvers";
13 | import typeDefs from "./typeDefs";
14 |
15 | (async () => {
16 | const port = process.env.CONTENT_SERVICE_PORT;
17 | const deleteProfileQueue = await initDeleteProfileQueue();
18 |
19 | deleteProfileQueue.listen(
20 | { interval: 5000, maxReceivedCount: 5 },
21 | onDeleteProfile
22 | );
23 |
24 | const schema = applyMiddleware(
25 | buildFederatedSchema([{ typeDefs, resolvers }]),
26 | permissions
27 | );
28 |
29 | const server = new ApolloServer({
30 | schema,
31 | context: ({ req }) => {
32 | const user = req.headers.user ? JSON.parse(req.headers.user) : null;
33 | return { user };
34 | },
35 | dataSources: () => {
36 | return {
37 | contentAPI: new ContentDataSource({ Post, Profile, Reply })
38 | };
39 | }
40 | });
41 |
42 | initMongoose();
43 |
44 | const { url } = await server.listen({ port });
45 | console.log(`Content service ready at ${url}`);
46 | })();
47 |
--------------------------------------------------------------------------------
/client/src/pages/Profile/index.js:
--------------------------------------------------------------------------------
1 | import { Route } from "react-router-dom";
2 | import { useQuery } from "@apollo/client";
3 | import React from "react";
4 |
5 | import { GET_PROFILE } from "../../graphql/queries";
6 | import { useAuth } from "../../context/AuthContext";
7 | import Loader from "../../components/Loader";
8 | import MainLayout from "../../layouts/MainLayout";
9 | import NotFound from "../NotFound";
10 | import ProfileHeader from "../../components/ProfileHeader";
11 | import ProfileTabs from "../../components/ProfileTabs";
12 |
13 | const Profile = ({ match }) => {
14 | const { checkingSession, viewerQuery } = useAuth();
15 | let username;
16 |
17 | if (match.params.username) {
18 | username = match.params.username;
19 | } else if (viewerQuery.data && viewerQuery.data.viewer.profile) {
20 | username = viewerQuery.data.viewer.profile.username;
21 | }
22 |
23 | const { data, error, loading, refetch } = useQuery(GET_PROFILE, {
24 | skip: !username,
25 | variables: { username }
26 | });
27 |
28 | if (checkingSession || loading) {
29 | return (
30 |
31 |
32 |
33 | );
34 | } else if (data && data.profile) {
35 | return (
36 |
37 |
38 |
39 |
40 | );
41 | } else if (error) {
42 | return ;
43 | }
44 |
45 | return {null};
46 | };
47 |
48 | export default Profile;
49 |
--------------------------------------------------------------------------------
/client/src/components/PinnedItemListItem/index.js:
--------------------------------------------------------------------------------
1 | import { Anchor, Box, Text } from "grommet";
2 | import { Book, Code } from "grommet-icons";
3 | import React from "react";
4 |
5 | import HoverBox from "../HoverBox";
6 |
7 | const PinnedItemListItem = ({ pinnedItemData }) => {
8 | const { description, name, primaryLanguage, url } = pinnedItemData;
9 | const isRepo = primaryLanguage;
10 |
11 | return (
12 |
22 |
32 | {isRepo ? : }
33 |
34 |
35 |
36 | {name}
37 |
38 | {description && (
39 |
40 | {description}
41 |
42 | )}
43 | {isRepo && (
44 |
45 | {primaryLanguage}
46 |
47 | )}
48 |
49 |
50 | );
51 | };
52 |
53 | export default PinnedItemListItem;
54 |
--------------------------------------------------------------------------------
/client/src/pages/Settings/Profile/index.js:
--------------------------------------------------------------------------------
1 | import { Redirect } from "react-router-dom";
2 | import { Text } from "grommet";
3 | import React, { useRef, useState } from "react";
4 |
5 | import { useAuth } from "../../../context/AuthContext";
6 | import CreateProfileForm from "../../../components/CreateProfileForm";
7 | import EditProfileForm from "../../../components/EditProfileForm";
8 | import Modal from "../../../components/Modal";
9 |
10 | const Profile = ({ history }) => {
11 | const [modalOpen, setModalOpen] = useState(true);
12 | const { viewerQuery, updateViewer } = useAuth();
13 | const { id, profile } = viewerQuery.data.viewer;
14 | const profileRef = useRef(profile);
15 |
16 | if (!profileRef.current && profile) {
17 | return ;
18 | }
19 |
20 | return (
21 | {
25 | setModalOpen(false);
26 | history.push(`/profile/${profile.username}`);
27 | })
28 | }
29 | isOpen={modalOpen}
30 | title={profile ? "Edit Profile" : "Create Profile"}
31 | width="600px"
32 | >
33 |
34 | {profile
35 | ? "Update your user information below:"
36 | : "Please create your user profile before proceeding:"}
37 | {" "}
38 | {profile ? (
39 |
40 | ) : (
41 |
42 | )}
43 |
44 | );
45 | };
46 |
47 | export default Profile;
48 |
--------------------------------------------------------------------------------
/nginx/default.template:
--------------------------------------------------------------------------------
1 | upstream web {
2 | server web:3000;
3 | }
4 |
5 | upstream graphql {
6 | server graphql:4000;
7 | }
8 |
9 | server {
10 | listen 80;
11 | server_name ${DOMAIN};
12 | server_tokens off;
13 | client_max_body_size 5M;
14 |
15 | location /.well-known/acme-challenge/ {
16 | root /var/www/certbot;
17 | }
18 |
19 | location / {
20 | return 301 https://$host$request_uri;
21 | }
22 | }
23 |
24 | server {
25 | listen 443 ssl;
26 | server_name ${DOMAIN};
27 | server_tokens off;
28 |
29 | ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
30 | ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
31 | include /etc/letsencrypt/options-ssl-nginx.conf;
32 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
33 |
34 | location / {
35 | proxy_pass http://web;
36 | proxy_set_header Host $http_host;
37 | proxy_set_header X-Real-IP $remote_addr;
38 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
39 | }
40 |
41 | location /graphql {
42 | proxy_pass http://graphql/graphql;
43 | proxy_set_header Host $http_host;
44 | proxy_set_header X-Real-IP $remote_addr;
45 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
46 | }
47 |
48 | location /sockjs-node {
49 | proxy_pass http://web;
50 | proxy_http_version 1.1;
51 | proxy_set_header Upgrade $http_upgrade;
52 | proxy_set_header Connection "Upgrade";
53 | }
54 | }
--------------------------------------------------------------------------------
/server/src/services/profiles/index.js:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from "apollo-server";
2 | import { applyMiddleware } from "graphql-middleware";
3 | import { buildFederatedSchema } from "@apollo/federation";
4 |
5 | import {
6 | initDeleteAccountQueue,
7 | initDeleteProfileQueue,
8 | onDeleteAccount
9 | } from "./queues";
10 | import auth0 from "../../config/auth0";
11 | import initMongoose from "../../config/mongoose";
12 | import permissions from "./permissions";
13 | import Profile from "../../models/Profile";
14 | import ProfilesDataSource from "./datasources/ProfilesDataSource";
15 | import resolvers from "./resolvers";
16 | import typeDefs from "./typeDefs";
17 |
18 | (async () => {
19 | const port = process.env.PROFILES_SERVICE_PORT;
20 | const deleteAccountQueue = await initDeleteAccountQueue();
21 | const deleteProfileQueue = await initDeleteProfileQueue();
22 |
23 | deleteAccountQueue.listen(
24 | { interval: 5000, maxReceivedCount: 5 },
25 | payload => {
26 | onDeleteAccount(payload, deleteProfileQueue);
27 | }
28 | );
29 |
30 | const schema = applyMiddleware(
31 | buildFederatedSchema([{ typeDefs, resolvers }]),
32 | permissions
33 | );
34 |
35 | const server = new ApolloServer({
36 | schema,
37 | context: ({ req }) => {
38 | const user = req.headers.user ? JSON.parse(req.headers.user) : null;
39 | return { user };
40 | },
41 | dataSources: () => {
42 | return {
43 | profilesAPI: new ProfilesDataSource({ auth0, Profile })
44 | };
45 | }
46 | });
47 |
48 | initMongoose();
49 |
50 | const { url } = await server.listen({ port });
51 | console.log(`Profiles service ready at ${url}`);
52 | })();
53 |
--------------------------------------------------------------------------------
/server/src/services/profiles/permissions.js:
--------------------------------------------------------------------------------
1 | import { rule, shield, and } from "graphql-shield";
2 |
3 | import getPermissions from "../../lib/getPermissions";
4 |
5 | const canReadAnyProfile = rule()((parent, args, { user }, info) => {
6 | const permissions = getPermissions(user);
7 | return permissions ? permissions.includes("read:any_profile") : false;
8 | });
9 |
10 | const canEditOwnProfile = rule()((parent, args, { user }, info) => {
11 | const permissions = getPermissions(user);
12 | return permissions ? permissions.includes("edit:own_profile") : false;
13 | });
14 |
15 | const isCreatingOwnProfile = rule()(
16 | (parent, { data: { accountId } }, { user }, info) => {
17 | return user.sub === accountId;
18 | }
19 | );
20 |
21 | const isEditingOwnProfile = rule()(
22 | async (parent, { where: { username } }, { user, dataSources }, info) => {
23 | const profile = await dataSources.profilesAPI.Profile.findOne({ username });
24 | return profile && user.sub === profile.accountId;
25 | }
26 | );
27 |
28 | const permissions = shield(
29 | {
30 | Query: {
31 | profile: canReadAnyProfile,
32 | profiles: canReadAnyProfile,
33 | searchProfiles: canReadAnyProfile
34 | },
35 | Mutation: {
36 | createProfile: and(canEditOwnProfile, isCreatingOwnProfile),
37 | deleteProfile: and(canEditOwnProfile, isEditingOwnProfile),
38 | followProfile: and(canEditOwnProfile, isEditingOwnProfile),
39 | unfollowProfile: and(canEditOwnProfile, isEditingOwnProfile),
40 | updateProfile: and(canEditOwnProfile, isEditingOwnProfile)
41 | }
42 | },
43 | { debug: process.env.NODE_ENV === "development" }
44 | );
45 |
46 | export default permissions;
47 |
--------------------------------------------------------------------------------
/client/src/components/DeleteAccountModal/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Button, Text } from "grommet";
2 | import { useMutation } from "@apollo/client";
3 | import React, { useState } from "react";
4 |
5 | import { DELETE_ACCOUNT } from "../../graphql/mutations";
6 | import { useAuth } from "../../context/AuthContext";
7 | import Modal from "../Modal";
8 |
9 | const DeleteAccountModal = ({ accountId }) => {
10 | const [modalOpen, setModalOpen] = useState(false);
11 | const { logout } = useAuth();
12 | const [deleteAccount, { loading }] = useMutation(DELETE_ACCOUNT, {
13 | onCompleted: logout
14 | });
15 |
16 | return (
17 |
18 | {
20 | setModalOpen(false)
21 | }}
22 | isOpen={modalOpen}
23 | title="Please Confirm"
24 | width="medium"
25 | >
26 |
27 | Are you sure you want to permanently delete your account and all of
28 | its content?
29 |
30 | This action cannot be reversed.
31 |
32 |
44 |
45 |
53 | );
54 | };
55 |
56 | export default DeleteAccountModal;
57 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | DevChirps
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/client/src/graphql/apollo.js:
--------------------------------------------------------------------------------
1 | import {
2 | ApolloClient,
3 | ApolloLink,
4 | ApolloProvider,
5 | InMemoryCache
6 | } from "@apollo/client";
7 | import { createPersistedQueryLink } from "apollo-link-persisted-queries";
8 | import { createUploadLink } from "apollo-upload-client";
9 | import { onError } from "apollo-link-error";
10 | import { setContext } from "@apollo/client/link/context";
11 | import React from "react";
12 |
13 | import { useAuth } from "../context/AuthContext";
14 | import typePolicies from "./typePolicies";
15 |
16 | const cache = new InMemoryCache({ typePolicies });
17 |
18 | const createApolloClient = getToken => {
19 | const uploadLink = createUploadLink({
20 | uri: process.env.REACT_APP_GRAPHQL_ENDPOINT
21 | });
22 | const authLink = setContext(async (request, { headers }) => {
23 | const accessToken = await getToken();
24 | return {
25 | headers: { ...headers, Authorization: `Bearer ${accessToken}` }
26 | };
27 | });
28 | const errorLink = onError(({ graphQLErrors, networkError }) => {
29 | if (graphQLErrors) {
30 | graphQLErrors.forEach(({ extensions: { serviceName }, message, path }) =>
31 | console.error(
32 | `[GraphQL error]: Message: ${message}, Service: ${serviceName}, Path: ${path[0]}`
33 | )
34 | );
35 | }
36 | if (networkError) {
37 | console.error(`[Network error]: ${networkError}`);
38 | }
39 | });
40 | const persistedQueryLink = createPersistedQueryLink();
41 |
42 | return new ApolloClient({
43 | cache,
44 | link: ApolloLink.from([errorLink, persistedQueryLink, authLink, uploadLink])
45 | });
46 | };
47 |
48 | const ApolloProviderWithAuth = ({ children }) => {
49 | const { getToken } = useAuth();
50 | const client = createApolloClient(getToken);
51 |
52 | return {children};
53 | };
54 |
55 | export { createApolloClient };
56 | export default ApolloProviderWithAuth;
57 |
--------------------------------------------------------------------------------
/server/src/config/apollo.js:
--------------------------------------------------------------------------------
1 | import { ApolloGateway, RemoteGraphQLDataSource } from "@apollo/gateway";
2 | import { ApolloServer } from "apollo-server-express";
3 | import { RedisCache } from "apollo-server-cache-redis";
4 | import depthLimit from "graphql-depth-limit";
5 | import waitOn from "wait-on";
6 |
7 | import { readNestedFileStreams } from "../lib/handleUploads";
8 |
9 | export default async function() {
10 | const options = {
11 | resources: [
12 | `tcp:${process.env.ACCOUNTS_SERVICE_PORT}`,
13 | `tcp:${process.env.PROFILES_SERVICE_PORT}`,
14 | `tcp:${process.env.CONTENT_SERVICE_PORT}`
15 | ]
16 | };
17 |
18 | await waitOn(options).catch(error => {
19 | console.log("Error waiting for services:", error);
20 | });
21 |
22 | const gateway = new ApolloGateway({
23 | serviceList: [
24 | { name: "accounts", url: process.env.ACCOUNTS_SERVICE_URL },
25 | { name: "profiles", url: process.env.PROFILES_SERVICE_URL },
26 | { name: "content", url: process.env.CONTENT_SERVICE_URL }
27 | ],
28 | buildService({ name, url }) {
29 | return new RemoteGraphQLDataSource({
30 | url,
31 | async willSendRequest({ request, context }) {
32 | await readNestedFileStreams(request.variables);
33 | request.http.headers.set(
34 | "user",
35 | context.user ? JSON.stringify(context.user) : null
36 | );
37 | }
38 | });
39 | }
40 | });
41 |
42 | return new ApolloServer({
43 | gateway,
44 | subscriptions: false,
45 | context: ({ req }) => {
46 | const user = req.user || null;
47 | return { user };
48 | },
49 | persistedQueries: {
50 | cache: new RedisCache({
51 | ...(process.env.NODE_ENV === "production" && {
52 | password: process.env.REDIS_PASSWORD
53 | }),
54 | host: process.env.REDIS_HOST_ADDRESS,
55 | post: process.env.REDIS_PORT,
56 | ttl: 600
57 | })
58 | },
59 | validationRules: [depthLimit(10)]
60 | });
61 | }
62 |
--------------------------------------------------------------------------------
/client/src/pages/Post/index.js:
--------------------------------------------------------------------------------
1 | import { Box } from "grommet";
2 | import { Route } from "react-router-dom";
3 | import { useQuery } from "@apollo/client";
4 | import React from "react";
5 |
6 | import { GET_POST } from "../../graphql/queries";
7 | import { updateSubfieldPageResults } from "../../lib/updateQueries";
8 | import ContentList from "../../components/ContentList";
9 | import Loader from "../../components/Loader";
10 | import LoadMoreButton from "../../components/LoadMoreButton";
11 | import MainLayout from "../../layouts/MainLayout";
12 | import NotFound from "../NotFound";
13 | import SingleContent from "../../components/SingleContent";
14 |
15 | const Post = ({ match }) => {
16 | const { data, fetchMore, loading } = useQuery(GET_POST, {
17 | variables: {
18 | id: match.params.id
19 | }
20 | });
21 |
22 | if (loading) {
23 | return (
24 |
25 |
26 |
27 |
28 |
29 | );
30 | } else if (data && data.post) {
31 | const { post } = data;
32 |
33 | return (
34 |
35 |
36 |
37 | {post.replies.pageInfo.hasNextPage && (
38 |
39 |
41 | fetchMore({
42 | variables: { repliesCursor: post.replies.pageInfo.endCursor },
43 | updateQuery: (previousResult, { fetchMoreResult }) =>
44 | updateSubfieldPageResults(
45 | "post",
46 | "replies",
47 | fetchMoreResult,
48 | previousResult
49 | )
50 | })
51 | }
52 | />
53 |
54 | )}
55 |
56 | );
57 | }
58 |
59 | return ;
60 | };
61 |
62 | export default Post;
63 |
--------------------------------------------------------------------------------
/server/src/services/accounts/permissions.js:
--------------------------------------------------------------------------------
1 | import { rule, shield, and, or } from "graphql-shield";
2 |
3 | import getPermissions from "../../lib/getPermissions";
4 |
5 | const canBlockAccount = rule()((parent, args, { user }, info) => {
6 | const userPermissions = getPermissions(user);
7 | return userPermissions && userPermissions.includes("block:any_account");
8 | });
9 |
10 | const canEditOwnAccount = rule()((parent, args, { user }, info) => {
11 | const userPermissions = getPermissions(user);
12 | return userPermissions && userPermissions.includes("edit:own_account");
13 | });
14 |
15 | const canPromoteAccount = rule()((parent, args, { user }, info) => {
16 | const userPermissions = getPermissions(user);
17 | return userPermissions && userPermissions.includes("promote:any_account");
18 | });
19 |
20 | const canReadAnyAccount = rule()((parent, args, { user }, info) => {
21 | const userPermissions = getPermissions(user);
22 | return userPermissions && userPermissions.includes("read:any_account");
23 | });
24 |
25 | const canReadOwnAccount = rule()((parent, args, { user }, info) => {
26 | const userPermissions = getPermissions(user);
27 | return userPermissions && userPermissions.includes("read:own_account");
28 | });
29 |
30 | const isReadingOwnAccount = rule()((parent, { id }, { user }, info) => {
31 | return user.sub === id;
32 | });
33 |
34 | const isEditingOwnAccount = rule()(
35 | (parent, { where: { id } }, { user }, info) => {
36 | return user.sub === id;
37 | }
38 | );
39 |
40 | const permissions = shield(
41 | {
42 | Query: {
43 | account: or(
44 | and(canReadOwnAccount, isReadingOwnAccount),
45 | canReadAnyAccount
46 | ),
47 | accounts: canReadAnyAccount
48 | },
49 | Mutation: {
50 | changeAccountBlockedStatus: canBlockAccount,
51 | changeAccountModeratorRole: canPromoteAccount,
52 | deleteAccount: and(canEditOwnAccount, isEditingOwnAccount),
53 | updateAccount: and(canEditOwnAccount, isEditingOwnAccount)
54 | }
55 | },
56 | { debug: process.env.NODE_ENV === "development" }
57 | );
58 |
59 | export default permissions;
60 |
--------------------------------------------------------------------------------
/client/src/pages/Home/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Text } from "grommet";
2 | import { useQuery } from "@apollo/client";
3 | import React from "react";
4 |
5 | import { GET_POSTS } from "../../graphql/queries";
6 | import { updateFieldPageResults } from "../../lib/updateQueries";
7 | import { useAuth } from "../../context/AuthContext";
8 | import ContentList from "../../components/ContentList";
9 | import Loader from "../../components/Loader";
10 | import LoadMoreButton from "../../components/LoadMoreButton";
11 | import MainLayout from "../../layouts/MainLayout";
12 | import SearchForm from "../../components/SearchForm";
13 |
14 | const Home = () => {
15 | const value = useAuth();
16 |
17 | const { data, fetchMore, loading } = useQuery(GET_POSTS, {
18 | variables: {
19 | filter: {
20 | followedBy: value.viewerQuery.data.viewer.profile.username,
21 | includeBlocked: false
22 | }
23 | }
24 | });
25 |
26 | if (loading) {
27 | return (
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | const { posts } = data;
37 |
38 | return (
39 |
40 |
41 |
42 | {posts.edges.length ? (
43 | <>
44 |
45 | {posts.pageInfo.hasNextPage && (
46 |
47 |
49 | fetchMore({
50 | variables: { cursor: posts.pageInfo.endCursor },
51 | updateQuery: (previousResult, { fetchMoreResult }) =>
52 | updateFieldPageResults(
53 | "posts",
54 | fetchMoreResult,
55 | previousResult
56 | )
57 | })
58 | }
59 | />
60 |
61 | )}
62 | >
63 | ) : (
64 | Nothing to display in your feed yet!
65 | )}
66 |
67 |
68 | );
69 | };
70 |
71 | export default Home;
72 |
--------------------------------------------------------------------------------
/server/src/lib/Queue.js:
--------------------------------------------------------------------------------
1 | import util from "util";
2 |
3 | const wait = util.promisify(setTimeout);
4 |
5 | class Queue {
6 | constructor(rsmq, qname) {
7 | this.rsmq = rsmq;
8 | this.qname = qname;
9 | }
10 |
11 | async createQueue() {
12 | const queues = await this.rsmq.listQueuesAsync();
13 |
14 | if (queues.find(queue => queue === this.qname)) {
15 | return;
16 | }
17 |
18 | const response = await this.rsmq.createQueueAsync({
19 | qname: this.qname,
20 | vt: 1
21 | });
22 |
23 | if (response !== 1) {
24 | throw new Error(`${this.qname} could not be created`);
25 | }
26 |
27 | console.log(`${this.qname} successfully created`);
28 | }
29 |
30 | async deleteQueue() {
31 | const response = await this.rsmq.deleteQueueAsync({ qname: this.qname });
32 |
33 | if (response !== 1) {
34 | console.log(`Queue could not be deleted`);
35 | return;
36 | }
37 |
38 | console.log(`${this.qname} queue and all messages deleted`);
39 | }
40 |
41 | async sendMessage(message) {
42 | const response = await this.rsmq.sendMessageAsync({
43 | qname: this.qname,
44 | message
45 | });
46 |
47 | if (!response) {
48 | throw new Error(`Message could not be sent to ${this.qname}`);
49 | }
50 |
51 | return response;
52 | }
53 |
54 | async receiveMessage() {
55 | const response = await this.rsmq.receiveMessageAsync({ qname: this.qname });
56 |
57 | if (!response || !response.id) {
58 | return null;
59 | }
60 |
61 | return response;
62 | }
63 |
64 | async deleteMessage(id) {
65 | const response = await this.rsmq.deleteMessageAsync({
66 | qname: this.qname,
67 | id
68 | });
69 |
70 | return response === 1;
71 | }
72 |
73 | async listen({ interval = 10000, maxReceivedCount = 10 }, callback) {
74 | const start = Date.now();
75 |
76 | try {
77 | const response = await this.receiveMessage();
78 |
79 | if (response && response.rc > maxReceivedCount) {
80 | await this.deleteMessage(response.id);
81 | } else if (response) {
82 | callback(response);
83 | await this.deleteMessage(response.id);
84 | }
85 | } finally {
86 | const elapsedTime = Date.now() - start;
87 | const waitTime = interval - elapsedTime;
88 |
89 | await wait(Math.max(0, waitTime));
90 | await this.listen({ interval, maxReceivedCount }, callback);
91 | }
92 | }
93 | }
94 |
95 | export default Queue;
96 |
--------------------------------------------------------------------------------
/client/src/components/SearchForm/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Button, Form, FormField, Select } from "grommet";
2 | import { Search } from "grommet-icons";
3 | import { useHistory, useLocation } from "react-router-dom";
4 | import queryString from "query-string";
5 | import React, { useState } from "react";
6 |
7 | const SearchForm = () => {
8 | const options = [
9 | { label: "For Posts", value: "searchPosts" },
10 | { label: "For Profiles", value: "searchProfiles" }
11 | ];
12 |
13 | const history = useHistory();
14 | const location = useLocation();
15 | const qsValues = queryString.parse(location.search);
16 |
17 | const [text, setText] = useState((qsValues && qsValues.text) || "");
18 | const [type, setType] = useState(
19 | (qsValues &&
20 | qsValues.type &&
21 | options.find(option => option.value === qsValues.type)) ||
22 | ""
23 | );
24 |
25 | return (
26 |
78 | );
79 | };
80 |
81 | export default SearchForm;
82 |
--------------------------------------------------------------------------------
/server/src/services/accounts/resolvers.js:
--------------------------------------------------------------------------------
1 | import { DateTimeResolver } from "../../lib/customScalars";
2 |
3 | const resolvers = {
4 | DateTime: DateTimeResolver,
5 |
6 | Account: {
7 | __resolveReference(reference, { dataSources }) {
8 | return dataSources.accountsAPI.getAccountById(reference.id);
9 | },
10 | id(account, args, context, info) {
11 | return account.user_id;
12 | },
13 | createdAt(account, args, context, info) {
14 | return account.created_at;
15 | },
16 | isBlocked(account, args, context, info) {
17 | return account.blocked;
18 | },
19 | isModerator(account, args, context, info) {
20 | return (
21 | account.app_metadata &&
22 | account.app_metadata.roles &&
23 | account.app_metadata.roles.includes("moderator")
24 | );
25 | }
26 | },
27 |
28 | Query: {
29 | account(_, { id }, { dataSources }, info) {
30 | return dataSources.accountsAPI.getAccountById(id);
31 | },
32 | accounts(parent, args, { dataSources }, info) {
33 | return dataSources.accountsAPI.getAccounts();
34 | },
35 | viewer(parent, args, { dataSources, user }, info) {
36 | if (user && user.sub) {
37 | return dataSources.accountsAPI.getAccountById(user.sub);
38 | }
39 | return null;
40 | }
41 | },
42 |
43 | Mutation: {
44 | changeAccountBlockedStatus(
45 | parent,
46 | { where: { id } },
47 | { dataSources },
48 | info
49 | ) {
50 | return dataSources.accountsAPI.changeAccountBlockedStatus(id);
51 | },
52 | changeAccountModeratorRole(
53 | parent,
54 | { where: { id } },
55 | { dataSources },
56 | info
57 | ) {
58 | return dataSources.accountsAPI.changeAccountModeratorRole(id);
59 | },
60 | createAccount(
61 | parent,
62 | { data: { email, password } },
63 | { dataSources },
64 | info
65 | ) {
66 | return dataSources.accountsAPI.createAccount(email, password);
67 | },
68 | async deleteAccount(
69 | parent,
70 | { where: { id } },
71 | { dataSources, queues },
72 | info
73 | ) {
74 | const accountDeleted = await dataSources.accountsAPI.deleteAccount(id);
75 |
76 | if (accountDeleted) {
77 | await queues.deleteAccountQueue.sendMessage(
78 | JSON.stringify({ accountId: id })
79 | );
80 | }
81 |
82 | return accountDeleted;
83 | },
84 | updateAccount(parent, { data, where: { id } }, { dataSources }, info) {
85 | return dataSources.accountsAPI.updateAccount(id, data);
86 | }
87 | }
88 | };
89 |
90 | export default resolvers;
91 |
--------------------------------------------------------------------------------
/client/src/components/NavBar/index.js:
--------------------------------------------------------------------------------
1 | import { Anchor, Box, Button, Heading, Menu } from "grommet";
2 | import { Menu as MenuIcon } from "grommet-icons";
3 | import { useHistory, useLocation } from "react-router-dom";
4 | import React, { useState } from "react";
5 |
6 | import { useAuth } from "../../context/AuthContext";
7 | import CreateContentForm from "../CreateContentForm";
8 | import Modal from "../Modal";
9 |
10 | const NavBar = () => {
11 | const { logout, viewerQuery } = useAuth();
12 | const history = useHistory();
13 | const location = useLocation();
14 | const [modalOpen, setModalOpen] = useState(false);
15 |
16 | return (
17 |
18 |
30 |
31 |
32 |
33 | {location.pathname !== "/" && (
34 |
35 | setModalOpen(false)}
37 | isOpen={modalOpen}
38 | title="Create a New Post"
39 | width="large"
40 | >
41 |
42 |
43 |
44 |
50 | }
54 | items={[
55 | {
56 | label: "My Profile",
57 | onClick: () => {
58 | history.push(
59 | `/profile/${viewerQuery.data.viewer.profile.username}`
60 | );
61 | }
62 | },
63 | {
64 | label: "Account Settings",
65 | onClick: () => {
66 | history.push("/settings/account");
67 | }
68 | },
69 | { label: "Logout", onClick: logout }
70 | ]}
71 | justifyContent="end"
72 | />
73 |
74 | )}
75 |
76 |
77 | );
78 | };
79 |
80 | export default NavBar;
81 |
--------------------------------------------------------------------------------
/client/src/pages/Search/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Text } from "grommet";
2 | import { useQuery } from "@apollo/client";
3 | import queryString from "query-string";
4 | import React from "react";
5 |
6 | import { SEARCH_POSTS, SEARCH_PROFILES } from "../../graphql/queries";
7 | import { updateFieldPageResults } from "../../lib/updateQueries";
8 | import ContentList from "../../components/ContentList";
9 | import Loader from "../../components/Loader";
10 | import LoadMoreButton from "../../components/LoadMoreButton";
11 | import MainLayout from "../../layouts/MainLayout";
12 | import ProfileList from "../../components/ProfileList";
13 | import SearchForm from "../../components/SearchForm";
14 |
15 | const Search = ({ location }) => {
16 | const { text, type } = queryString.parse(location.search);
17 |
18 | const SEARCH_QUERY = type === "searchPosts" ? SEARCH_POSTS : SEARCH_PROFILES;
19 | const { data, fetchMore, loading } = useQuery(SEARCH_QUERY, {
20 | variables: { query: { text: text || "" } }
21 | });
22 |
23 | if (loading) {
24 | return (
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | return (
34 |
35 |
36 |
37 | {data && data[type] && data[type].edges.length ? (
38 | <>
39 | {data.searchPosts ? (
40 |
41 | ) : (
42 |
43 | )}
44 | {data[type].pageInfo.hasNextPage && (
45 |
46 |
48 | fetchMore({
49 | variables: { cursor: data[type].pageInfo.endCursor },
50 | updateQuery: (previousResult, { fetchMoreResult }) =>
51 | updateFieldPageResults(
52 | type,
53 | fetchMoreResult,
54 | previousResult
55 | )
56 | })
57 | }
58 | />
59 |
60 | )}
61 | >
62 | ) : (
63 |
64 | {text &&
65 | type &&
66 | (type === "searchPosts" || type === "searchProfiles")
67 | ? "Sorry, no results found for that search phrase!"
68 | : "Submit a search query above to see results."}
69 |
70 | )}
71 |
72 |
73 | );
74 | };
75 |
76 | export default Search;
77 |
--------------------------------------------------------------------------------
/server/src/services/accounts/typeDefs.js:
--------------------------------------------------------------------------------
1 | import { gql } from "apollo-server";
2 |
3 | const typeDefs = gql`
4 | # SCALARS
5 |
6 | """
7 | An ISO 8601-encoded UTC date string...
8 | """
9 | scalar DateTime
10 |
11 | # OBJECTS
12 |
13 | """
14 | An account is an Auth0 user that provides authentication details.
15 | """
16 | type Account @key(fields: "id") {
17 | "The unique Auth0 ID associated with the account."
18 | id: ID!
19 | "The date and time the account was created."
20 | createdAt: DateTime!
21 | "The email associated with the account (must be unique)."
22 | email: String
23 | "Whether the account is blocked."
24 | isBlocked: Boolean
25 | "Whether the account has a moderator role."
26 | isModerator: Boolean
27 | }
28 |
29 | # INPUTS
30 |
31 | """
32 | Provides the unique ID of an existing account.
33 | """
34 | input AccountWhereUniqueInput {
35 | "The unique Auth0 ID associated with the account."
36 | id: ID!
37 | }
38 |
39 | """
40 | Provides data to create a new account.
41 | """
42 | input CreateAccountInput {
43 | "The new account's email (must be unique)."
44 | email: String!
45 | "The new account's password."
46 | password: String!
47 | }
48 |
49 | """
50 | Provides data to update an existing account.
51 |
52 | A current password and new password are required to update a password.
53 |
54 | Password and email fields cannot be updated simultaneously.
55 | """
56 | input UpdateAccountInput {
57 | "The updated account email."
58 | email: String
59 | "The updated account password."
60 | newPassword: String
61 | "The existing account password."
62 | password: String
63 | }
64 |
65 | # QUERIES & MUTATIONS
66 |
67 | extend type Query {
68 | "Retrieves a single account bu Auth0 ID."
69 | account(id: ID!): Account!
70 |
71 | "Retrieves a list of accounts."
72 | accounts: [Account]
73 |
74 | "Retrieves the currently logged in account from Auth0."
75 | viewer: Account
76 | }
77 |
78 | extend type Mutation {
79 | "Blocks or unblocks an account from authenticating."
80 | changeAccountBlockedStatus(where: AccountWhereUniqueInput!): Account!
81 |
82 | "Escalates or deescalates moderator role permissions."
83 | changeAccountModeratorRole(where: AccountWhereUniqueInput!): Account!
84 |
85 | "Creates a new account."
86 | createAccount(data: CreateAccountInput!): Account!
87 |
88 | "Deletes an account."
89 | deleteAccount(where: AccountWhereUniqueInput!): Boolean!
90 |
91 | "Updates an account's details."
92 | updateAccount(
93 | data: UpdateAccountInput!
94 | where: AccountWhereUniqueInput!
95 | ): Account!
96 | }
97 | `;
98 |
99 | export default typeDefs;
100 |
--------------------------------------------------------------------------------
/client/src/context/AuthContext.js:
--------------------------------------------------------------------------------
1 | import createAuth0Client from "@auth0/auth0-spa-js";
2 | import React, { createContext, useContext, useEffect, useState } from "react";
3 |
4 | import { GET_VIEWER } from "../graphql/queries";
5 | import { createApolloClient } from "../graphql/apollo";
6 | import history from "../routes/history";
7 |
8 | const AuthContext = createContext();
9 | const useAuth = () => useContext(AuthContext);
10 |
11 | const initOptions = {
12 | audience: process.env.REACT_APP_GRAPHQL_ENDPOINT,
13 | client_id: process.env.REACT_APP_AUTH0_CLIENT_ID,
14 | domain: process.env.REACT_APP_AUTH0_DOMAIN,
15 | redirect_uri: process.env.REACT_APP_AUTH0_CALLBACK_URL
16 | };
17 |
18 | const AuthProvider = ({ children }) => {
19 | const [auth0Client, setAuth0Client] = useState();
20 | const [checkingSession, setCheckingSession] = useState(true);
21 | const [isAuthenticated, setIsAuthenticated] = useState(false);
22 | const [viewerQuery, setViewerQuery] = useState(null);
23 |
24 | useEffect(() => {
25 | const initializeAuth0 = async () => {
26 | try {
27 | const client = await createAuth0Client(initOptions);
28 | setAuth0Client(client);
29 |
30 | if (window.location.search.includes("code=")) {
31 | await client.handleRedirectCallback();
32 | history.replace({ pathname: "/home", search: "" });
33 | }
34 |
35 | const authenticated = await client.isAuthenticated();
36 | setIsAuthenticated(authenticated);
37 |
38 | if (history.location.pathname === "/login" && authenticated) {
39 | history.replace("/home");
40 | } else if (history.location.pathname === "/login") {
41 | history.replace("/");
42 | } else if (authenticated) {
43 | const apolloClient = createApolloClient((...p) =>
44 | client.getTokenSilently(...p)
45 | );
46 | const viewer = await apolloClient.query({ query: GET_VIEWER });
47 | setViewerQuery(viewer);
48 | }
49 | } catch {
50 | history.location.pathname !== "/" && history.replace("/");
51 | } finally {
52 | setCheckingSession(false);
53 | }
54 | };
55 | initializeAuth0();
56 | }, []);
57 |
58 | return (
59 | auth0Client.getTokenSilently(...p),
63 | isAuthenticated,
64 | login: (...p) => auth0Client.loginWithRedirect(...p),
65 | logout: (...p) =>
66 | auth0Client.logout({ ...p, returnTo: process.env.AUTH0_LOGOUT_URL }),
67 | updateViewer: viewer =>
68 | setViewerQuery({ ...viewerQuery, data: { viewer } }),
69 | viewerQuery
70 | }}
71 | >
72 | {children}
73 |
74 | );
75 | };
76 |
77 | export { AuthProvider, useAuth };
78 | export default AuthContext;
79 |
--------------------------------------------------------------------------------
/server/src/services/content/permissions.js:
--------------------------------------------------------------------------------
1 | import { and, rule, shield } from "graphql-shield";
2 |
3 | import getPermissions from "../../lib/getPermissions";
4 |
5 | const canReadAnyContent = rule()((parent, args, { user }, info) => {
6 | const userPermissions = getPermissions(user);
7 | return userPermissions && userPermissions.includes("read:any_content");
8 | });
9 |
10 | const canEditOwnContent = rule()((parent, args, { user }, info) => {
11 | const userPermissions = getPermissions(user);
12 | return userPermissions && userPermissions.includes("edit:own_content");
13 | });
14 |
15 | const canBlockAnyContent = rule()((parent, args, { user }, info) => {
16 | const userPermissions = getPermissions(user);
17 | return userPermissions && userPermissions.includes("block:any_content");
18 | });
19 |
20 | const isCreatingOwnContent = rule()(
21 | async (parent, { data: { username } }, { user, dataSources }, info) => {
22 | const profile = await dataSources.contentAPI.Profile.findOne({
23 | username
24 | }).exec();
25 |
26 | if (!profile || !user || !user.sub) {
27 | return false;
28 | }
29 |
30 | return user.sub === profile.accountId;
31 | }
32 | );
33 |
34 | const isEditingOwnPost = rule()(
35 | async (parent, { where: { id } }, { user, dataSources }, info) => {
36 | if (!user || !user.sub) {
37 | return false;
38 | }
39 |
40 | const profile = await dataSources.contentAPI.Profile.findOne({
41 | accountId: user.sub
42 | }).exec();
43 | const post = await dataSources.contentAPI.Post.findById(id);
44 |
45 | if (!profile || !post) {
46 | return false;
47 | }
48 |
49 | return profile._id.toString() === post.authorProfileId.toString();
50 | }
51 | );
52 |
53 | const isEditingOwnReply = rule()(
54 | async (parent, { where: { id } }, { user, dataSources }, info) => {
55 | if (!user || !user.sub) {
56 | return false;
57 | }
58 |
59 | const profile = await dataSources.contentAPI.Profile.findOne({
60 | accountId: user.sub
61 | }).exec();
62 | const reply = await dataSources.contentAPI.Reply.findById(id);
63 |
64 | if (!profile || !reply) {
65 | return false;
66 | }
67 |
68 | return profile._id.toString() === reply.authorProfileId.toString();
69 | }
70 | );
71 |
72 | const permissions = shield(
73 | {
74 | Query: {
75 | post: canReadAnyContent,
76 | posts: canReadAnyContent,
77 | reply: canReadAnyContent,
78 | replies: canReadAnyContent,
79 | searchPosts: canReadAnyContent
80 | },
81 | Mutation: {
82 | createPost: and(canEditOwnContent, isCreatingOwnContent),
83 | createReply: and(canEditOwnContent, isCreatingOwnContent),
84 | deletePost: and(canEditOwnContent, isEditingOwnPost),
85 | deleteReply: and(canEditOwnContent, isEditingOwnReply),
86 | togglePostBlock: canBlockAnyContent,
87 | toggleReplyBlock: canBlockAnyContent
88 | }
89 | },
90 | { debug: process.env.NODE_ENV === "development" }
91 | );
92 |
93 | export default permissions;
94 |
--------------------------------------------------------------------------------
/server/src/lib/handleUploads.js:
--------------------------------------------------------------------------------
1 | import fetch from "node-fetch";
2 |
3 | import cloudinary from "../config/cloudinary";
4 |
5 | export function deleteUpload(url) {
6 | const public_id = url
7 | .split("/")
8 | .slice(-3)
9 | .join("/")
10 | .split(".")[0];
11 |
12 | return new Promise((resolve, reject) => {
13 | cloudinary.api.delete_resources(
14 | [public_id],
15 | { invalidate: true, type: "authenticated" },
16 | (error, result) => {
17 | if (error) {
18 | return reject(error);
19 | }
20 | resolve(result);
21 | }
22 | );
23 | });
24 | }
25 |
26 | export function deleteUserUploads(profileId) {
27 | const prefix = `${process.env.NODE_ENV}/${profileId}`;
28 |
29 | return new Promise((resolve, reject) => {
30 | cloudinary.api.delete_resources_by_prefix(
31 | prefix,
32 | { invalidate: true, type: "authenticated" },
33 | (error, result) => {
34 | if (error) {
35 | return reject(error);
36 | }
37 | resolve(result);
38 | }
39 | );
40 | });
41 | }
42 |
43 | export function deleteUserUploadsDir(profileId) {
44 | return fetch(
45 | `https://api.cloudinary.com/v1_1/${process.env.CLOUDINARY_CLOUD_NAME}/folders/${process.env.NODE_ENV}/${profileId}`,
46 | {
47 | method: "DELETE",
48 | headers: {
49 | Authorization: `Basic ${Buffer.from(
50 | `${process.env.CLOUDINARY_API_KEY}:${process.env.CLOUDINARY_API_SECRET}`
51 | ).toString("base64")}`
52 | }
53 | }
54 | );
55 | }
56 |
57 | function onReadStream(stream) {
58 | return new Promise((resolve, reject) => {
59 | let buffers = [];
60 | stream.on("error", error => reject(error));
61 | stream.on("data", data => buffers.push(data));
62 | stream.on("end", () => {
63 | const contents = Buffer.concat(buffers);
64 | resolve(contents);
65 | });
66 | });
67 | }
68 |
69 | export async function readNestedFileStreams(variables) {
70 | const varArr = Object.entries(variables || {});
71 |
72 | for (let i = 0; i < varArr.length; i++) {
73 | if (Boolean(varArr[i][1] && typeof varArr[i][1].then === "function")) {
74 | const { createReadStream, encoding, filename, mimetype } = await varArr[
75 | i
76 | ][1];
77 | const readStream = createReadStream();
78 | const buffer = await onReadStream(readStream);
79 | variables[varArr[i][0]] = { buffer, encoding, filename, mimetype };
80 | }
81 |
82 | if (varArr[i][1] !== null && varArr[i][1].constructor.name === "Object") {
83 | await readNestedFileStreams(varArr[i][1]);
84 | }
85 | }
86 |
87 | return variables;
88 | }
89 |
90 | export function uploadStream(buffer, options) {
91 | return new Promise((resolve, reject) => {
92 | cloudinary.uploader
93 | .upload_stream(options, (error, result) => {
94 | if (error) {
95 | return reject(error);
96 | }
97 | resolve(result);
98 | })
99 | .end(buffer);
100 | });
101 | }
102 |
--------------------------------------------------------------------------------
/scripts/init-letsencrypt.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Usage:
4 | # $ chmod +x init-letsencrypt.sh
5 | # $ init-letsencrypt.sh mydomain.com bob@email.com 1
6 | #
7 | # Reference:
8 | # https://medium.com/@pentacent/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
9 |
10 | domain=${1}
11 | rsa_key_size=4096
12 | data_path="/home/chirpsdev/devchirps/certbot"
13 | email=${2:-""}
14 | staging=${3:-0}
15 |
16 | if [ -d "$data_path" ]; then
17 | read -p "Existing data found for $domain. Continue and replace existing certificate? (y/N) " decision
18 | if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
19 | exit
20 | fi
21 | fi
22 |
23 | if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
24 | echo "### Downloading recommended TLS parameters ..."
25 | mkdir -p "$data_path/conf"
26 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
27 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
28 | echo
29 | fi
30 |
31 | echo "### Removing old certificate for $domain ..."
32 | docker-compose -f docker-compose.yml -f docker-compose.prod.yml run --rm --entrypoint "\
33 | rm -Rf /etc/letsencrypt/live/$domain && \
34 | rm -Rf /etc/letsencrypt/archive/$domain && \
35 | rm -Rf /etc/letsencrypt/renewal/$domain.conf" certbot
36 | echo
37 |
38 | echo "### Creating dummy certificate for $domain ..."
39 | path="/etc/letsencrypt/live/$domain"
40 | mkdir -p "$data_path/conf/live/$domain"
41 | docker-compose -f docker-compose.yml -f docker-compose.prod.yml run --rm --entrypoint "\
42 | openssl req -x509 -nodes -newkey rsa:1024 -days 1\
43 | -keyout "$path/privkey.pem" \
44 | -out "$path/fullchain.pem" \
45 | -subj '/CN=localhost'" certbot
46 | echo
47 |
48 | echo "### Starting containers ..."
49 | docker-compose -f docker-compose.yml -f docker-compose.prod.yml up --force-recreate -d
50 | echo
51 |
52 | echo "### Deleting dummy certificate for $domain ..."
53 | docker-compose -f docker-compose.yml -f docker-compose.prod.yml run --rm --entrypoint "\
54 | rm -Rf /etc/letsencrypt/live/$domain && \
55 | rm -Rf /etc/letsencrypt/archive/$domain && \
56 | rm -Rf /etc/letsencrypt/renewal/$domain.conf" certbot
57 | echo
58 |
59 | echo "### Requesting Let's Encrypt certificate for $domain ..."
60 |
61 | # Select appropriate email arg
62 | case "$email" in
63 | "") email_arg="--register-unsafely-without-email" ;;
64 | *) email_arg="--email $email" ;;
65 | esac
66 |
67 | # Enable staging mode if needed
68 | if [ $staging != "0" ]; then staging_arg="--staging"; fi
69 |
70 | docker-compose -f docker-compose.yml -f docker-compose.prod.yml run --rm --entrypoint "\
71 | certbot certonly --webroot -w /var/www/certbot \
72 | $staging_arg \
73 | $email_arg \
74 | -d $domain \
75 | --rsa-key-size $rsa_key_size \
76 | --agree-tos \
77 | --force-renewal" certbot
78 | echo
79 |
80 | echo "### Reloading nginx ..."
81 | docker-compose -f docker-compose.yml -f docker-compose.prod.yml exec nginx nginx -s reload
82 | echo
83 |
84 | echo "### Stopping containers ..."
85 | docker-compose -f docker-compose.yml -f docker-compose.prod.yml stop
86 |
--------------------------------------------------------------------------------
/client/src/components/NewReplyModal/index.js:
--------------------------------------------------------------------------------
1 | import { Anchor, Button, Box, Image, Text } from "grommet";
2 | import { ChatOption } from "grommet-icons";
3 | import { Link } from "react-router-dom";
4 | import React, { useState } from "react";
5 |
6 | import { displayRelativeDateOrTime } from "../../lib/displayDatetime";
7 | import CreateContentForm from "../CreateContentForm";
8 | import Modal from "../Modal";
9 |
10 | const NewReplyModal = ({ iconSize, postData, showButtonLabel }) => {
11 | const [modalOpen, setModalOpen] = useState(false);
12 |
13 | const { author, createdAt, id, text } = postData;
14 |
15 | return (
16 | {
19 | event.stopPropagation();
20 | }}
21 | >
22 | {
24 | setModalOpen(false);
25 | }}
26 | isOpen={modalOpen}
27 | title="Create a New Reply"
28 | width="large"
29 | >
30 |
31 |
32 |
39 |
44 |
45 |
46 |
47 | {author.fullName}{" "}
48 |
49 |
50 |
51 | @{author.username}
52 |
53 |
54 |
55 |
56 |
57 | {text}
58 |
59 |
65 | {displayRelativeDateOrTime(createdAt)}
66 |
67 |
68 |
69 |
70 | Replying to
71 |
72 | @{author.username}
73 |
74 | :
75 |
76 |
77 |
78 |
79 |
86 | }
87 | label={showButtonLabel && "Reply"}
88 | onClick={() => setModalOpen(!modalOpen)}
89 | primary={showButtonLabel}
90 | />
91 |
92 | );
93 | };
94 |
95 | NewReplyModal.defaultProps = {
96 | iconSize: "small",
97 | showButtonLabel: true
98 | };
99 |
100 | export default NewReplyModal;
101 |
--------------------------------------------------------------------------------
/server/src/services/profiles/resolvers.js:
--------------------------------------------------------------------------------
1 | import { UserInputError } from "apollo-server";
2 |
3 | const resolvers = {
4 | Account: {
5 | profile(account, args, { dataSources }, info) {
6 | return dataSources.profilesAPI.getProfile(
7 | { accountId: account.id },
8 | info
9 | );
10 | }
11 | },
12 |
13 | PinnableItem: {
14 | id(pinnableItem, args, context, info) {
15 | return pinnableItem.githubId;
16 | }
17 | },
18 |
19 | Profile: {
20 | __resolveReference(reference, { dataSources }, info) {
21 | return dataSources.profilesAPI.getProfileById(reference.id, info);
22 | },
23 | account(profile, args, context, info) {
24 | return { __typename: "Account", id: profile.accountId };
25 | },
26 | following(profile, args, { dataSources }, info) {
27 | return dataSources.profilesAPI.getFollowedProfiles(
28 | { ...args, following: profile.following },
29 | info
30 | );
31 | },
32 | id(profile, args, context, info) {
33 | return profile._id;
34 | },
35 | viewerIsFollowing(profile, args, { dataSources, user }, info) {
36 | return dataSources.profilesAPI.checkViewerFollowsProfile(
37 | user.sub,
38 | profile._id
39 | );
40 | }
41 | },
42 |
43 | Query: {
44 | async profile(parent, { username }, { dataSources }, info) {
45 | const profile = await dataSources.profilesAPI.getProfile(
46 | { username },
47 | info
48 | );
49 |
50 | if (!profile) {
51 | throw new UserInputError("Profile does not exist.");
52 | }
53 | return profile;
54 | },
55 | profiles(parent, args, { dataSources }, info) {
56 | return dataSources.profilesAPI.getProfiles(args, info);
57 | },
58 | searchProfiles(
59 | parent,
60 | { after, first, query: { text } },
61 | { dataSources },
62 | info
63 | ) {
64 | return dataSources.profilesAPI.searchProfiles(
65 | { after, first, searchString: text },
66 | info
67 | );
68 | }
69 | },
70 |
71 | Mutation: {
72 | createProfile(parent, { data }, { dataSources }, info) {
73 | return dataSources.profilesAPI.createProfile(data);
74 | },
75 | deleteProfile(parent, { where: { username } }, { dataSources }, info) {
76 | return dataSources.profilesAPI.deleteProfile(username);
77 | },
78 | followProfile(
79 | parent,
80 | { data: { followingProfileId }, where: { username } },
81 | { dataSources },
82 | info
83 | ) {
84 | return dataSources.profilesAPI.followProfile(
85 | username,
86 | followingProfileId
87 | );
88 | },
89 | unfollowProfile(
90 | parent,
91 | { data: { followingProfileId }, where: { username } },
92 | { dataSources },
93 | info
94 | ) {
95 | return dataSources.profilesAPI.unfollowProfile(
96 | username,
97 | followingProfileId
98 | );
99 | },
100 | updateProfile(
101 | parent,
102 | { data, where: { username: currentUsername } },
103 | { dataSources },
104 | info
105 | ) {
106 | return dataSources.profilesAPI.updateProfile(currentUsername, data);
107 | }
108 | }
109 | };
110 |
111 | export default resolvers;
112 |
--------------------------------------------------------------------------------
/client/src/components/CreateProfileForm/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Button, Form, FormField } from "grommet";
2 | import { useMutation } from "@apollo/client";
3 | import React, { useState } from "react";
4 |
5 | import { CREATE_PROFILE } from "../../graphql/mutations";
6 | import { GET_VIEWER } from "../../graphql/queries";
7 | import CharacterCountLabel from "../CharacterCountLabel";
8 | import Loader from "../Loader";
9 | import RequiredLabel from "../RequiredLabel";
10 |
11 | const CreateProfileForm = ({ accountId, updateViewer }) => {
12 | const [descCharCount, setDescCharCount] = useState(0);
13 | const [createProfile, { error, loading }] = useMutation(CREATE_PROFILE, {
14 | update: (cache, { data: { createProfile } }) => {
15 | const { viewer } = cache.readQuery({ query: GET_VIEWER });
16 | const viewerWithProfile = { ...viewer, profile: createProfile };
17 | cache.writeQuery({
18 | query: GET_VIEWER,
19 | data: { viewer: viewerWithProfile }
20 | });
21 | updateViewer(viewerWithProfile);
22 | }
23 | });
24 |
25 | return (
26 |
96 | );
97 | };
98 |
99 | export default CreateProfileForm;
100 |
--------------------------------------------------------------------------------
/client/src/graphql/mutations.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | import { basicProfile } from "./fragments";
4 |
5 | export const CHANGE_ACCOUNT_BLOCKED_STATUS = gql`
6 | mutation CHANGE_ACCOUNT_BLOCKED_STATUS($where: AccountWhereUniqueInput!) {
7 | changeAccountBlockedStatus(where: $where) {
8 | id
9 | isBlocked
10 | }
11 | }
12 | `;
13 |
14 | export const CHANGE_ACCOUNT_MODERATOR_ROLE = gql`
15 | mutation CHANGE_ACCOUNT_MODERATOR_ROLE($where: AccountWhereUniqueInput!) {
16 | changeAccountModeratorRole(where: $where) {
17 | id
18 | isModerator
19 | }
20 | }
21 | `;
22 |
23 | export const CREATE_POST = gql`
24 | mutation CREATE_POST($data: CreatePostInput!) {
25 | createPost(data: $data) {
26 | id
27 | }
28 | }
29 | `;
30 |
31 | export const CREATE_PROFILE = gql`
32 | mutation CREATE_PROFILE($data: CreateProfileInput!) {
33 | createProfile(data: $data) {
34 | ...basicProfile
35 | }
36 | }
37 | ${basicProfile}
38 | `;
39 |
40 | export const CREATE_REPLY = gql`
41 | mutation CREATE_REPLY($data: CreateReplyInput!) {
42 | createReply(data: $data) {
43 | id
44 | post {
45 | id
46 | }
47 | }
48 | }
49 | `;
50 |
51 | export const DELETE_ACCOUNT = gql`
52 | mutation DELETE_ACCOUNT($where: AccountWhereUniqueInput!) {
53 | deleteAccount(where: $where)
54 | }
55 | `;
56 |
57 | export const DELETE_POST = gql`
58 | mutation DELETE_POST($where: ContentWhereUniqueInput!) {
59 | deletePost(where: $where)
60 | }
61 | `;
62 |
63 | export const DELETE_REPLY = gql`
64 | mutation DELETE_REPLY($where: ContentWhereUniqueInput!) {
65 | deleteReply(where: $where)
66 | }
67 | `;
68 |
69 | export const FOLLOW_PROFILE = gql`
70 | mutation FOLLOW_PROFILE(
71 | $data: FollowingProfileInput!
72 | $where: ProfileWhereUniqueInput!
73 | ) {
74 | followProfile(data: $data, where: $where) {
75 | id
76 | }
77 | }
78 | `;
79 |
80 | export const TOGGLE_POST_BLOCK = gql`
81 | mutation TOGGLE_POST_BLOCK($where: ContentWhereUniqueInput!) {
82 | togglePostBlock(where: $where) {
83 | id
84 | isBlocked
85 | }
86 | }
87 | `;
88 |
89 | export const TOGGLE_REPLY_BLOCK = gql`
90 | mutation TOGGLE_REPLY_BLOCK($where: ContentWhereUniqueInput!) {
91 | toggleReplyBlock(where: $where) {
92 | id
93 | isBlocked
94 | }
95 | }
96 | `;
97 |
98 | export const UNFOLLOW_PROFILE = gql`
99 | mutation UNFOLLOW_PROFILE(
100 | $data: FollowingProfileInput!
101 | $where: ProfileWhereUniqueInput!
102 | ) {
103 | unfollowProfile(data: $data, where: $where) {
104 | id
105 | }
106 | }
107 | `;
108 |
109 | export const UPDATE_ACCOUNT = gql`
110 | mutation UPDATE_ACCOUNT(
111 | $data: UpdateAccountInput!
112 | $where: AccountWhereUniqueInput!
113 | ) {
114 | updateAccount(data: $data, where: $where) {
115 | id
116 | }
117 | }
118 | `;
119 |
120 | export const UPDATE_PROFILE = gql`
121 | mutation UPDATE_PROFILE(
122 | $data: UpdateProfileInput!
123 | $where: ProfileWhereUniqueInput!
124 | ) {
125 | updateProfile(data: $data, where: $where) {
126 | ...basicProfile
127 | githubUrl
128 | pinnedItems {
129 | id
130 | description
131 | name
132 | primaryLanguage
133 | url
134 | }
135 | }
136 | }
137 | ${basicProfile}
138 | `;
139 |
--------------------------------------------------------------------------------
/client/src/components/DeleteContentModal/index.js:
--------------------------------------------------------------------------------
1 | import { Button, Box, Text } from "grommet";
2 | import { Trash } from "grommet-icons";
3 | import { useHistory } from "react-router-dom";
4 | import { useMutation } from "@apollo/client";
5 | import React, { useState } from "react";
6 |
7 | import { DELETE_POST, DELETE_REPLY } from "../../graphql/mutations";
8 |
9 | import {
10 | GET_POST,
11 | GET_POSTS,
12 | GET_PROFILE_CONTENT
13 | } from "../../graphql/queries";
14 | import { useAuth } from "../../context/AuthContext";
15 | import Modal from "../Modal";
16 |
17 | const DeleteContentModal = ({ iconSize, id, isReply, parentPostId }) => {
18 | const history = useHistory();
19 | const [modalOpen, setModalOpen] = useState(false);
20 | const value = useAuth();
21 | const { username } = value.viewerQuery.data.viewer.profile;
22 |
23 | const onCompleted = () => {
24 | setModalOpen(false);
25 | history.push("/home");
26 | };
27 | const [deletePost, { loading }] = useMutation(DELETE_POST, {
28 | onCompleted,
29 | refetchQueries: () => [
30 | {
31 | query: GET_POSTS,
32 | variables: {
33 | filter: {
34 | followedBy: username,
35 | includeBlocked: false
36 | }
37 | }
38 | },
39 | {
40 | query: GET_PROFILE_CONTENT,
41 | variables: { username }
42 | }
43 | ]
44 | });
45 | const [deleteReply] = useMutation(DELETE_REPLY, {
46 | onCompleted,
47 | refetchQueries: () => [
48 | ...(parentPostId
49 | ? [{ query: GET_POST, variables: { id: parentPostId } }]
50 | : []),
51 | {
52 | query: GET_PROFILE_CONTENT,
53 | variables: { username }
54 | }
55 | ]
56 | });
57 |
58 | return (
59 | event.stopPropagation()}>
60 | {
62 | setModalOpen(false)
63 | }}
64 | isOpen={modalOpen}
65 | title="Please Confirm"
66 | width="medium"
67 | >
68 |
69 | {`Are you sure you want to permanently delete this ${
70 | isReply ? "reply" : "post"
71 | }?`}
72 |
73 |
74 |
96 |
97 | }
100 | onClick={() => {
101 | setModalOpen(true)
102 | }}
103 | />
104 |
105 | );
106 | };
107 |
108 | DeleteContentModal.defaultProps = {
109 | iconSize: "small",
110 | isReply: false,
111 | parentPostId: null
112 | };
113 |
114 | export default DeleteContentModal;
115 |
--------------------------------------------------------------------------------
/client/src/components/ProfileListItem/index.js:
--------------------------------------------------------------------------------
1 | import { Anchor, Box, Button, Image, Text } from "grommet";
2 | import { Link, useLocation, useParams, useHistory } from "react-router-dom";
3 | import { useMutation } from "@apollo/client";
4 | import queryString from "query-string";
5 | import React from "react";
6 |
7 | import { FOLLOW_PROFILE, UNFOLLOW_PROFILE } from "../../graphql/mutations";
8 | import {
9 | updateProfileContentFollowing,
10 | updateSearchProfilesFollowing
11 | } from "../../lib/updateQueries";
12 | import { useAuth } from "../../context/AuthContext";
13 | import HoverBox from "../HoverBox";
14 |
15 | const ProfileListItem = ({ profileData }) => {
16 | const {
17 | avatar,
18 | description,
19 | fullName,
20 | id,
21 | username,
22 | viewerIsFollowing
23 | } = profileData;
24 |
25 | const history = useHistory();
26 | const location = useLocation();
27 | const params = useParams();
28 | const value = useAuth();
29 | const { username: viewerUsername } = value.viewerQuery.data.viewer.profile;
30 |
31 | const update = cache => {
32 | if (params.username) {
33 | updateProfileContentFollowing(cache, id, params.username);
34 | } else if (location.pathname === "/search") {
35 | const { text } = queryString.parse(location.search);
36 | updateSearchProfilesFollowing(cache, id, text);
37 | }
38 | };
39 | const [followProfile, { loading }] = useMutation(FOLLOW_PROFILE, { update });
40 | const [unfollowProfile] = useMutation(UNFOLLOW_PROFILE, { update });
41 |
42 | const variables = {
43 | data: {
44 | followingProfileId: id
45 | },
46 | where: {
47 | username: viewerUsername
48 | }
49 | };
50 |
51 | return (
52 | {
61 | history.push(`/profile/${username}`);
62 | }}
63 | pad={{ left: "small", top: "medium", right: "small" }}
64 | >
65 |
72 |
73 |
74 |
75 |
76 | {fullName}{" "}
77 |
78 |
79 | @{username}
80 |
81 |
82 |
83 |
84 | {description}
85 |
86 |
87 | {viewerUsername !== username && (
88 | {
89 | event.stopPropagation();
90 | }}>
91 |
104 | )}
105 |
106 | );
107 | };
108 |
109 | export default ProfileListItem;
110 |
--------------------------------------------------------------------------------
/server/src/services/accounts/datasources/AccountsDataSource.js:
--------------------------------------------------------------------------------
1 | import { DataSource } from "apollo-datasource";
2 | import { UserInputError } from "apollo-server";
3 | import DataLoader from "dataloader";
4 |
5 | import getToken from "../../../lib/getToken";
6 |
7 | class AccountsDataSource extends DataSource {
8 | authorPermissions = [
9 | "read:own_account",
10 | "edit:own_account",
11 | "read:any_profile",
12 | "edit:own_profile",
13 | "read:any_content",
14 | "edit:own_content",
15 | "upload:own_media"
16 | ];
17 |
18 | moderatorPermissions = [
19 | "read:any_account",
20 | "block:any_accounts",
21 | "promote:any_accounts",
22 | "block:any_content"
23 | ];
24 |
25 | _accountByIdLoader = new DataLoader(async ids => {
26 | const q = ids.map(id => `user_id:${id}`).join(" OR ");
27 | const accounts = await this.auth0.getUsers({ search_engine: "v3", q });
28 |
29 | return ids.map(id => accounts.find(account => account.user_id === id));
30 | });
31 |
32 | constructor({ auth0 }) {
33 | super();
34 | this.auth0 = auth0;
35 | }
36 |
37 | // CREATE
38 |
39 | createAccount(email, password) {
40 | return this.auth0.createUser({
41 | app_metadata: {
42 | groups: [],
43 | roles: ["author"],
44 | permissions: this.authorPermissions
45 | },
46 | connection: "Username-Password-Authentication",
47 | email,
48 | password
49 | });
50 | }
51 |
52 | // READ
53 |
54 | getAccountById(id) {
55 | return this._accountByIdLoader.load(id);
56 | }
57 |
58 | getAccounts() {
59 | return this.auth0.getUsers();
60 | }
61 |
62 | // UPDATE
63 |
64 | async changeAccountBlockedStatus(id) {
65 | const { blocked } = await this.auth0.getUser({ id });
66 | return this.auth0.updateUser({ id }, { blocked: !blocked });
67 | }
68 |
69 | async changeAccountModeratorRole(id) {
70 | const user = await this.auth0.getUser({ id });
71 | const isModerator = user.app_metadata.roles.includes("moderator");
72 | const roles = isModerator ? ["author"] : ["moderator"];
73 | const permissions = isModerator
74 | ? this.authorPermissions
75 | : this.authorPermissions.concat(this.moderatorPermissions);
76 |
77 | return this.auth0.updateUser(
78 | { id },
79 | {
80 | app_metadata: {
81 | groups: [],
82 | roles,
83 | permissions
84 | }
85 | }
86 | );
87 | }
88 |
89 | async updateAccount(id, { email, newPassword, password }) {
90 | if (!email && !newPassword && !password) {
91 | throw new UserInputError("You must supply some account data to update.");
92 | } else if (email && newPassword && password) {
93 | throw new UserInputError(
94 | "Email and password cannot be updated simultaneously."
95 | );
96 | } else if ((!password && newPassword) || (password && !newPassword)) {
97 | throw new UserInputError(
98 | "Provide the existing and new passwords when updating the password."
99 | );
100 | }
101 |
102 | if (!email) {
103 | const user = await this.auth0.getUser({ id });
104 | await getToken(user.email, password);
105 | return this.auth0.updateUser({ id }, { password: newPassword });
106 | }
107 |
108 | return this.auth0.updateUser({ id }, { email });
109 | }
110 |
111 | // DELETE
112 |
113 | async deleteAccount(id) {
114 | await this.auth0.deleteUser({ id });
115 | return true;
116 | }
117 | }
118 |
119 | export default AccountsDataSource;
120 |
--------------------------------------------------------------------------------
/client/src/lib/updateQueries.js:
--------------------------------------------------------------------------------
1 | import update from "immutability-helper";
2 |
3 | import { GET_PROFILE_CONTENT, SEARCH_PROFILES } from "../graphql/queries";
4 |
5 | export function updateFieldPageResults(field, fetchMoreResult, previousResult) {
6 | const { edges: newEdges, pageInfo } = fetchMoreResult[field];
7 |
8 | return newEdges.length
9 | ? {
10 | [field]: {
11 | __typename: previousResult[field].__typename,
12 | edges: [...previousResult[field].edges, ...newEdges],
13 | pageInfo
14 | }
15 | }
16 | : previousResult;
17 | }
18 |
19 | export function updateProfileContentAuthor(cache, username, updatedAuthor) {
20 | let { profile } = cache.readQuery({
21 | query: GET_PROFILE_CONTENT,
22 | variables: { username }
23 | });
24 | const updatedPostsEdges = profile.posts.edges.map(edge =>
25 | update(edge, {
26 | node: { author: { $set: { ...edge.node.author, ...updatedAuthor } } }
27 | })
28 | );
29 | const updatedRepliesEdges = profile.replies.edges.map(edge =>
30 | update(edge, {
31 | node: { author: { $set: { ...edge.node.author, ...updatedAuthor } } }
32 | })
33 | );
34 |
35 | cache.writeQuery({
36 | query: GET_PROFILE_CONTENT,
37 | data: {
38 | profile: {
39 | ...profile,
40 | posts: { ...profile.posts, edges: updatedPostsEdges },
41 | replies: { ...profile.replies, edges: updatedRepliesEdges }
42 | }
43 | }
44 | });
45 | }
46 |
47 | export function updateProfileContentFollowing(cache, followingId, username) {
48 | let { profile } = cache.readQuery({
49 | query: GET_PROFILE_CONTENT,
50 | variables: { username }
51 | });
52 | const followingIndex = profile.following.edges.findIndex(
53 | item => item.node.id === followingId
54 | );
55 | const isFollowing =
56 | profile.following.edges[followingIndex].node.viewerIsFollowing;
57 | const updatedProfile = update(profile, {
58 | following: {
59 | edges: {
60 | [followingIndex]: {
61 | node: { viewerIsFollowing: { $set: !isFollowing } }
62 | }
63 | }
64 | }
65 | });
66 |
67 | cache.writeQuery({
68 | query: GET_PROFILE_CONTENT,
69 | data: { profile: updatedProfile }
70 | });
71 | }
72 |
73 | export function updateSearchProfilesFollowing(cache, followingId, text) {
74 | let { searchProfiles } = cache.readQuery({
75 | query: SEARCH_PROFILES,
76 | variables: { query: { text } }
77 | });
78 |
79 | const followingIndex = searchProfiles.edges.findIndex(
80 | item => item.node.id === followingId
81 | );
82 | const isFollowing =
83 | searchProfiles.edges[followingIndex].node.viewerIsFollowing;
84 | const updatedSearchProfiles = update(searchProfiles, {
85 | edges: {
86 | [followingIndex]: {
87 | node: { viewerIsFollowing: { $set: !isFollowing } }
88 | }
89 | }
90 | });
91 |
92 | cache.writeQuery({
93 | query: SEARCH_PROFILES,
94 | data: { searchProfiles: updatedSearchProfiles }
95 | });
96 | }
97 |
98 | export function updateSubfieldPageResults(
99 | field,
100 | subfield,
101 | fetchMoreResult,
102 | previousResult
103 | ) {
104 | const { edges: newEdges, pageInfo } = fetchMoreResult[field][subfield];
105 |
106 | return newEdges.length
107 | ? {
108 | [field]: {
109 | ...previousResult[field],
110 | [subfield]: {
111 | __typename: previousResult[field][subfield].__typename,
112 | edges: [...previousResult[field][subfield].edges, ...newEdges],
113 | pageInfo
114 | }
115 | }
116 | }
117 | : previousResult;
118 | }
119 |
--------------------------------------------------------------------------------
/server/src/services/content/resolvers.js:
--------------------------------------------------------------------------------
1 | import { DateTimeResolver } from "../../lib/customScalars";
2 |
3 | const resolvers = {
4 | DateTime: DateTimeResolver,
5 |
6 | Content: {
7 | __resolveType(content, context, info) {
8 | if (content.postId) {
9 | return "Reply";
10 | } else {
11 | return "Post";
12 | }
13 | }
14 | },
15 |
16 | Post: {
17 | author(post, args, context, info) {
18 | return { __typename: "Profile", id: post.authorProfileId };
19 | },
20 | id(post, args, context, info) {
21 | return post._id;
22 | },
23 | isBlocked(post, args, context, info) {
24 | return post.blocked;
25 | },
26 | replies(post, args, { dataSources }, info) {
27 | return dataSources.contentAPI.getPostReplies(
28 | { ...args, postId: post._id },
29 | info
30 | );
31 | }
32 | },
33 |
34 | Profile: {
35 | posts(profile, args, { dataSources }, info) {
36 | return dataSources.contentAPI.getOwnPosts(
37 | { ...args, authorProfileId: profile.id },
38 | info
39 | );
40 | },
41 | replies(profile, args, { dataSources }, info) {
42 | return dataSources.contentAPI.getOwnReplies(
43 | { ...args, authorProfileId: profile.id },
44 | info
45 | );
46 | }
47 | },
48 |
49 | Reply: {
50 | author(reply, args, context, info) {
51 | return { __typename: "Profile", id: reply.authorProfileId };
52 | },
53 | id(reply, args, context, info) {
54 | return reply._id;
55 | },
56 | isBlocked(reply, args, context, info) {
57 | return reply.blocked;
58 | },
59 | post(reply, args, { dataSources }, info) {
60 | return dataSources.contentAPI.getPostById(reply.postId, info);
61 | },
62 | postAuthor(reply, args, { dataSources }, info) {
63 | return { __typename: "Profile", id: reply.postAuthorProfileId };
64 | }
65 | },
66 |
67 | Query: {
68 | post(parent, { id }, { dataSources }, info) {
69 | return dataSources.contentAPI.getPostById(id, info);
70 | },
71 | posts(parent, args, { dataSources }, info) {
72 | return dataSources.contentAPI.getPosts(args, info);
73 | },
74 | reply(parent, { id }, { dataSources }, info) {
75 | return dataSources.contentAPI.getReplyById(id, info);
76 | },
77 | replies(parent, args, { dataSources }, info) {
78 | return dataSources.contentAPI.getReplies(args, info);
79 | },
80 | searchPosts(
81 | parent,
82 | { after, first, query: { text } },
83 | { dataSources },
84 | info
85 | ) {
86 | return dataSources.contentAPI.searchPosts(
87 | { after, first, searchString: text },
88 | info
89 | );
90 | }
91 | },
92 |
93 | Mutation: {
94 | createPost(parent, { data }, { dataSources }, info) {
95 | return dataSources.contentAPI.createPost(data);
96 | },
97 | createReply(parent, { data }, { dataSources }, info) {
98 | return dataSources.contentAPI.createReply(data);
99 | },
100 | deletePost(parent, { where: { id } }, { dataSources }, info) {
101 | return dataSources.contentAPI.deletePost(id);
102 | },
103 | deleteReply(parent, { where: { id } }, { dataSources }, info) {
104 | return dataSources.contentAPI.deleteReply(id);
105 | },
106 | togglePostBlock(parent, { where: { id } }, { dataSources }, info) {
107 | return dataSources.contentAPI.togglePostBlock(id);
108 | },
109 | toggleReplyBlock(parent, { where: { id } }, { dataSources }, info) {
110 | return dataSources.contentAPI.toggleReplyBlock(id);
111 | }
112 | }
113 | };
114 |
115 | export default resolvers;
116 |
--------------------------------------------------------------------------------
/client/src/graphql/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | import {
4 | basicPost,
5 | basicProfile,
6 | basicReply,
7 | postsNextPage,
8 | repliesNextPage,
9 | profilesNextPage
10 | } from "./fragments";
11 |
12 | export const GET_POST = gql`
13 | query GET_POST($id: ID!, $repliesCursor: String) {
14 | post(id: $id) {
15 | ...basicPost
16 | replies(first: 30, after: $repliesCursor) {
17 | edges {
18 | node {
19 | ...basicReply
20 | }
21 | }
22 | ...repliesNextPage
23 | }
24 | }
25 | }
26 | ${basicPost}
27 | ${basicReply}
28 | ${repliesNextPage}
29 | `;
30 |
31 | export const GET_POSTS = gql`
32 | query GET_POSTS($cursor: String, $filter: PostWhereInput) {
33 | posts(first: 30, after: $cursor, filter: $filter) {
34 | edges {
35 | node {
36 | ...basicPost
37 | }
38 | }
39 | ...postsNextPage
40 | }
41 | }
42 | ${basicPost}
43 | ${postsNextPage}
44 | `;
45 |
46 | export const GET_PROFILE = gql`
47 | query GET_PROFILE($username: String!) {
48 | profile(username: $username) {
49 | ...basicProfile
50 | account {
51 | id
52 | createdAt
53 | isBlocked
54 | isModerator
55 | }
56 | viewerIsFollowing
57 | }
58 | }
59 | ${basicProfile}
60 | `;
61 |
62 | export const GET_PROFILE_CONTENT = gql`
63 | query GET_PROFILE_CONTENT(
64 | $followingCursor: String
65 | $postsCursor: String
66 | $repliesCursor: String
67 | $username: String!
68 | ) {
69 | profile(username: $username) {
70 | id
71 | following(first: 30, after: $followingCursor) {
72 | edges {
73 | node {
74 | ...basicProfile
75 | viewerIsFollowing
76 | }
77 | }
78 | ...profilesNextPage
79 | }
80 | pinnedItems {
81 | id
82 | description
83 | name
84 | primaryLanguage
85 | url
86 | }
87 | posts(first: 30, after: $postsCursor) {
88 | edges {
89 | node {
90 | ...basicPost
91 | }
92 | }
93 | ...postsNextPage
94 | }
95 | replies(first: 30, after: $repliesCursor) {
96 | edges {
97 | node {
98 | ...basicReply
99 | }
100 | }
101 | ...repliesNextPage
102 | }
103 | }
104 | }
105 | ${basicProfile}
106 | ${basicPost}
107 | ${basicReply}
108 | ${postsNextPage}
109 | ${profilesNextPage}
110 | ${repliesNextPage}
111 | `;
112 |
113 | export const GET_REPLY = gql`
114 | query GET_REPLY($id: ID!) {
115 | reply(id: $id) {
116 | ...basicReply
117 | post {
118 | ...basicPost
119 | }
120 | }
121 | }
122 | ${basicPost}
123 | ${basicReply}
124 | `;
125 |
126 | export const GET_VIEWER = gql`
127 | query GET_VIEWER {
128 | viewer {
129 | id
130 | createdAt
131 | email
132 | isModerator
133 | profile {
134 | ...basicProfile
135 | }
136 | }
137 | }
138 | ${basicProfile}
139 | `;
140 |
141 | export const SEARCH_POSTS = gql`
142 | query SEARCH_POSTS($cursor: String, $query: PostSearchInput!) {
143 | searchPosts(first: 30, after: $cursor, query: $query) {
144 | edges {
145 | node {
146 | ...basicPost
147 | }
148 | }
149 | ...postsNextPage
150 | }
151 | }
152 | ${basicPost}
153 | ${postsNextPage}
154 | `;
155 |
156 | export const SEARCH_PROFILES = gql`
157 | query SEARCH_PROFILES($cursor: String, $query: ProfileSearchInput!) {
158 | searchProfiles(first: 30, after: $cursor, query: $query) {
159 | edges {
160 | node {
161 | ...basicProfile
162 | viewerIsFollowing
163 | }
164 | }
165 | ...profilesNextPage
166 | }
167 | }
168 | ${basicProfile}
169 | ${profilesNextPage}
170 | `;
171 |
--------------------------------------------------------------------------------
/client/src/components/SingleContent/index.js:
--------------------------------------------------------------------------------
1 | import { Anchor, Box, Image, Text } from "grommet";
2 | import { Link } from "react-router-dom";
3 | import React from "react";
4 |
5 | import { displayFullDatetime } from "../../lib/displayDatetime";
6 | import { useAuth } from "../../context/AuthContext";
7 | import ContentBlockButton from "../ContentBlockButton";
8 | import DeleteContentModal from "../DeleteContentModal";
9 | import NewReplyModal from "../NewReplyModal";
10 | import NotAvailableMessage from "../NotAvailableMessage";
11 |
12 | const SingleContent = ({ contentData }) => {
13 | const value = useAuth();
14 | const {
15 | isModerator,
16 | profile: { username }
17 | } = value.viewerQuery.data.viewer;
18 |
19 | const {
20 | author,
21 | createdAt,
22 | id,
23 | isBlocked,
24 | media,
25 | post: parentPost,
26 | postAuthor: parentPostAuthor,
27 | text
28 | } = contentData;
29 |
30 | return (
31 |
41 |
42 |
49 |
54 |
55 |
56 | {author.fullName}
57 |
58 |
59 |
60 | @{author.username}
61 |
62 |
63 |
64 |
65 |
66 |
67 | {parentPostAuthor && (
68 |
69 | Replying to
70 |
71 | @{parentPostAuthor.username}
72 |
73 |
74 | )}
75 | {isBlocked && (
76 |
80 | )}
81 | {(!isBlocked || author.username === username) && (
82 | <>
83 |
84 | {text}
85 |
86 | {media && (
87 |
88 |
93 |
94 | )}
95 | >
96 | )}
97 |
98 |
99 |
100 | {displayFullDatetime(createdAt)}
101 |
102 | {author.username === username && (
103 |
109 | )}
110 | {isModerator && username !== author.username && (
111 |
117 | )}
118 |
119 | {parentPostAuthor === undefined && !isBlocked && (
120 |
121 |
125 |
126 | )}
127 |
128 | );
129 | };
130 |
131 | export default SingleContent;
132 |
--------------------------------------------------------------------------------
/client/src/components/ProfileHeader/index.js:
--------------------------------------------------------------------------------
1 | import { Anchor, Box, Button, Heading, Image, Text } from "grommet";
2 | import { Github } from "grommet-icons";
3 | import { useHistory } from "react-router-dom";
4 | import { useMutation } from "@apollo/client";
5 | import moment from "moment";
6 | import React from "react";
7 |
8 | import { FOLLOW_PROFILE, UNFOLLOW_PROFILE } from "../../graphql/mutations";
9 | import { useAuth } from "../../context/AuthContext";
10 | import AccountBlockButton from "../AccountBlockButton";
11 | import ModeratorRoleButton from "../ModeratorRoleButton";
12 | import NotAvailableMessage from "../NotAvailableMessage";
13 |
14 | const ProfileHeader = ({ profileData, refetchProfile }) => {
15 | const {
16 | account,
17 | avatar,
18 | description,
19 | fullName,
20 | githubUrl,
21 | id,
22 | username,
23 | viewerIsFollowing
24 | } = profileData;
25 |
26 | const value = useAuth();
27 | const {
28 | isModerator: viewerIsModerator,
29 | profile: { username: viewerUsername }
30 | } = value.viewerQuery.data.viewer;
31 | const history = useHistory();
32 | const [followProfile, { loading }] = useMutation(FOLLOW_PROFILE);
33 | const [unfollowProfile] = useMutation(UNFOLLOW_PROFILE);
34 |
35 | const variables = {
36 | data: {
37 | followingProfileId: id
38 | },
39 | where: {
40 | username: viewerUsername
41 | }
42 | };
43 |
44 | const renderButton = () => {
45 | let label, onClick;
46 |
47 | if (username === viewerUsername) {
48 | label = "Edit Profile";
49 | onClick = () => {
50 | history.push("/settings/profile");
51 | };
52 | } else if (viewerIsFollowing) {
53 | label = "Unfollow";
54 | onClick = async () => {
55 | await unfollowProfile({ variables });
56 | refetchProfile();
57 | };
58 | } else {
59 | label = "Follow";
60 | onClick = async () => {
61 | await followProfile({ variables });
62 | refetchProfile();
63 | };
64 | }
65 |
66 | return (
67 |
68 | );
69 | };
70 |
71 | return (
72 |
78 |
87 |
88 |
89 |
90 | {fullName && {fullName}}
91 |
92 |
93 | @{username} {account.isModerator && "(Moderator)"}
94 |
95 | {githubUrl && (
96 |
97 |
98 |
99 | )}
100 |
101 | {account.isBlocked && (
102 |
106 | )}
107 |
108 | {description ? description : "404: description not found."}
109 |
110 |
111 |
112 |
113 | Joined {moment(account.createdAt).format("MMMM YYYY")}
114 |
115 | {viewerIsModerator && username !== viewerUsername && (
116 |
121 | )}
122 | {viewerIsModerator && (
123 |
128 | )}
129 |
130 |
131 | {renderButton()}
132 |
133 | );
134 | };
135 |
136 | export default ProfileHeader;
137 |
--------------------------------------------------------------------------------
/client/src/components/ContentListItem/index.js:
--------------------------------------------------------------------------------
1 | import { Anchor, Box, Image, Text } from "grommet";
2 | import { Link, useHistory } from "react-router-dom";
3 | import React from "react";
4 |
5 | import { displayRelativeDateOrTime } from "../../lib/displayDatetime";
6 | import { useAuth } from "../../context/AuthContext";
7 | import DeleteContentModal from "../DeleteContentModal";
8 | import HoverBox from "../HoverBox";
9 | import NewReplyModal from "../NewReplyModal";
10 | import NotAvailableMessage from "../NotAvailableMessage";
11 |
12 | const ContentListItem = ({ contentData }) => {
13 | const value = useAuth();
14 | const { username } = value.viewerQuery.data.viewer.profile;
15 | const history = useHistory();
16 |
17 | const {
18 | author,
19 | createdAt,
20 | id,
21 | isBlocked,
22 | media,
23 | post: parentPost,
24 | postAuthor: parentPostAuthor,
25 | text
26 | } = contentData;
27 |
28 | return (
29 | {
38 | history.push(
39 | `/${parentPostAuthor !== undefined ? "reply" : "post"}/${id}`
40 | );
41 | }}
42 | pad={{ left: "small", top: "medium", right: "small" }}
43 | >
44 |
51 |
56 |
57 |
58 |
59 | {author.fullName}{" "}
60 |
61 | {
65 | event.stopPropagation();
66 | }}
67 | >
68 | @{author.username}
69 |
70 |
71 |
72 | {parentPostAuthor && (
73 |
74 | Replying to
75 |
76 | {
79 | event.stopPropagation();
80 | }}
81 | >
82 | @{parentPostAuthor.username}
83 |
84 |
85 |
86 | )}
87 | {parentPostAuthor === null && (
88 |
92 | )}
93 | {isBlocked && (
94 |
98 | )}
99 | {(!isBlocked || author.username === username) && (
100 | <>
101 |
102 | {text}
103 |
104 | {media && (
105 |
106 |
111 |
112 | )}
113 | >
114 | )}
115 |
116 |
117 | {displayRelativeDateOrTime(createdAt)}
118 |
119 | {parentPostAuthor === undefined && !isBlocked && (
120 |
125 | )}
126 | {author.username === username && (
127 |
133 | )}
134 |
135 |
136 |
137 | );
138 | };
139 |
140 | export default ContentListItem;
141 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Advanced GraphQL with Apollo & React (Source Code)
2 |
3 | This repo contains the completed files for the full-stack JavaScript application built throughout the _Advanced GraphQL with Apollo & React_ book from 8-Bit Press.
4 |
5 | **[Learn more about the book and download a sample chapter here.](https://8bit.press/book/advanced-graphql)**
6 |
7 | ## What's in the Book?
8 |
9 | If you've done basic GraphQL tutorials in the past and wondered, "alright, what's next?" then this book was written for you.
10 |
11 | Advanced topics covered in the book include:
12 |
13 | ### Apollo Federation and Gateway
14 |
15 | For anything beyond a toy app, it doesn't take long for a GraphQL schema to balloon in size. Apollo Federation allows you to tame that beast by drawing sensible boundaries between multiple services with their own federated schemas, and then declaratively composing them together into a single data graph with Apollo Gateway.
16 |
17 | ### Authentication and Authorization with Auth0
18 |
19 | Authentication and permissions-based authorization are some of the trickiest things to get right in an app. Using [Auth0](https://auth0.com/) we'll implement a fully-featured auth system that supports custom user roles and optional GitHub-based logins. Bonus! We'll also learn how to easily migrate an Auth0 tenant used for development purposes to a dedicated production tenant prior to deployment.
20 |
21 | ### Apollo Data Sources
22 |
23 | Bloating resolver functions with data-fetching logic can be messy and often isn't very DRY. We'll use Apollo data sources to neatly encapsulate and organize data-fetching logic within each service.
24 |
25 | ### Relay-Style Pagination
26 |
27 | Relay-style pagination is a popular choice for paginating results in a GraphQL API, but implementing its spec from scratch isn't for the faint of heart! We'll build out a helper class step-by-step to paginate documents retrieved from MongoDB using the Relay pagination algorithm.
28 |
29 | ### Apollo Client 3 with React Hooks
30 |
31 | We'll take a modern approach to build a React client app using function components only along with a variety of different hooks, including Apollo Client's `useQuery` and `useMutation` hooks.
32 |
33 | ### Manage Data in the Apollo Client Cache
34 |
35 | We'll use the new type policies feature of Apollo Client to customize how it caches data belonging to different types in a schema. We'll also explore multiple strategies for updating the cache to re-render components after a mutation completes or paginated queries are fetched.
36 |
37 | ### Message Queues with Redis
38 |
39 | Occasionally, the self-contained services that comprise an app will need a way to communicate with each other. We'll explore using Redis as a message queue so that we can cascade the act of deleting a user account and all of its related data and media assets from one service to the next.
40 |
41 | ### Automatic Persisted Queries
42 |
43 | Automatic Persisted Queries help improve network performance by sending smaller hashed representations of a query to Apollo Server. We'll use Redis as a scalable option for storing these query hashes.
44 |
45 | ### Batching with DataLoader
46 |
47 | When left unchecked, nested GraphQL queries can put a significant strain on the data stores behind them. The DataLoader library will allow us to batch requests to Auth0 and MongoDB and decrease the total number of requests per query by an order of magnitude.
48 |
49 | ### Enhanced Query Performance Using info
50 |
51 | The`info` argument of a resolver function is a somewhat mysterious creature given its general lack of documentation anywhere. We'll dig into the `info` object and use the data it provides about a given query's abstract syntax tree to optimize requests to the database.
52 |
53 | ### Deployment with Docker
54 |
55 | We'll see how we can use Docker to package up and deploy a React app, MongoDB, Redis, and four separate Node.js processes to Digital Ocean, and then also add nginx as a reverse proxy server and HTTPS with Let's Encrypt certificates to our production app.
56 |
57 | ## What Does All the Code in This Repo Do?
58 |
59 | The book takes a hands-on approach to learning advanced GraphQL concepts by building the back-end and front-end of a microblogging app called DevChirps from scratch (the code you see here!).
60 |
61 | DevChirps users can write posts and reply to them, add images to their posts and replies, follow other users, and search for content and users with a full-text search. Users with a moderator role can block content and other user accounts.
62 |
63 | Users who sign up for DevChirps with a GitHub account can showcase pinned repos and gists on their profile too.
64 |
65 | ## About the Author
66 |
67 | Mandi Wise discovered her love for building web things 20 years ago. She spent the last six years sharing that passion by teaching software development to others, including how to build web and mobile applications powered by GraphQL APIs. She currently works as a Solutions Architect for [Apollo Graph Inc.](https://www.apollographql.com/) You can find her on [GitHub](https://github.com/mandiwise) and [Twitter](https://twitter.com/mandiwise).
68 |
69 | ## Questions & Feedback
70 |
71 | Email [hello@8bit.press](mailto:hello@8bit.press) if you have any questions or feedback about this book.
72 |
73 | ---
74 |
75 | Copyright © 2020 8-Bit Press Inc.
76 |
--------------------------------------------------------------------------------
/client/src/components/ProfileTabs/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Tab, Tabs, Text } from "grommet";
2 | import { ChatOption, Group, Note, Pin } from "grommet-icons";
3 | import { useQuery } from "@apollo/client";
4 | import React from "react";
5 |
6 | import { GET_PROFILE_CONTENT } from "../../graphql/queries";
7 | import { updateSubfieldPageResults } from "../../lib/updateQueries";
8 | import ContentList from "../../components/ContentList";
9 | import Loader from "../Loader";
10 | import LoadMoreButton from "../LoadMoreButton";
11 | import PinnedItemList from "../PinnedItemList";
12 | import ProfileList from "../ProfileList";
13 | import RichTabTitle from "../RichTabTitle";
14 |
15 | const ProfileTabs = ({ username }) => {
16 | const { data, fetchMore, loading } = useQuery(GET_PROFILE_CONTENT, {
17 | variables: { username }
18 | });
19 |
20 | if (loading) {
21 | return (
22 |
23 |
24 |
25 | );
26 | }
27 |
28 | const {
29 | profile: { following, pinnedItems, posts, replies }
30 | } = data;
31 |
32 | return (
33 |
34 | } label="Posts" size="xsmall" />}>
35 |
36 | {posts.edges.length ? (
37 | <>
38 |
39 | {posts.pageInfo.hasNextPage && (
40 |
41 |
43 | fetchMore({
44 | variables: {
45 | postsCursor: posts.pageInfo.endCursor
46 | },
47 | updateQuery: (previousResult, { fetchMoreResult }) =>
48 | updateSubfieldPageResults(
49 | "profile",
50 | "posts",
51 | fetchMoreResult,
52 | previousResult
53 | )
54 | })
55 | }
56 | />
57 |
58 | )}
59 | >
60 | ) : (
61 | No posts to display yet!
62 | )}
63 |
64 |
65 | } label="Replies" size="xsmall" />
68 | }
69 | >
70 |
71 | {replies.edges.length ? (
72 | <>
73 |
74 | {replies.pageInfo.hasNextPage && (
75 |
76 |
78 | fetchMore({
79 | variables: {
80 | repliesCursor: replies.pageInfo.endCursor
81 | },
82 | updateQuery: (previousResult, { fetchMoreResult }) =>
83 | updateSubfieldPageResults(
84 | "profile",
85 | "replies",
86 | fetchMoreResult,
87 | previousResult
88 | )
89 | })
90 | }
91 | />
92 |
93 | )}
94 | >
95 | ) : (
96 | No replies to display yet!
97 | )}
98 |
99 |
100 | } label="Following" size="xsmall" />
103 | }
104 | >
105 |
106 | {following.edges.length ? (
107 | <>
108 |
109 | {following.pageInfo.hasNextPage && (
110 |
111 |
113 | fetchMore({
114 | variables: {
115 | followingCursor: following.pageInfo.endCursor
116 | },
117 | updateQuery: (previousResult, { fetchMoreResult }) =>
118 | updateSubfieldPageResults(
119 | "profile",
120 | "following",
121 | fetchMoreResult,
122 | previousResult
123 | )
124 | })
125 | }
126 | />
127 |
128 | )}
129 | >
130 | ) : (
131 | No followed users to display yet!
132 | )}
133 |
134 |
135 | {pinnedItems && pinnedItems.length && (
136 | } label="Code" size="xsmall" />}>
137 |
138 |
139 | )}
140 |
141 | );
142 | };
143 |
144 | export default ProfileTabs;
145 |
--------------------------------------------------------------------------------
/client/src/components/CreateContentForm/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | Form,
5 | FormField,
6 | Image,
7 | Stack,
8 | TextArea,
9 | TextInput
10 | } from "grommet";
11 | import { Close } from "grommet-icons";
12 | import { useHistory } from "react-router-dom";
13 | import { useMutation } from "@apollo/client";
14 | import React, { useRef, useState } from "react";
15 |
16 | import { CREATE_POST, CREATE_REPLY } from "../../graphql/mutations";
17 |
18 | import {
19 | GET_POST,
20 | GET_POSTS,
21 | GET_PROFILE_CONTENT
22 | } from "../../graphql/queries";
23 | import { useAuth } from "../../context/AuthContext";
24 | import CharacterCountLabel from "../CharacterCountLabel";
25 | import Loader from "../Loader";
26 | import RequiredLabel from "../RequiredLabel";
27 |
28 | const CreateContentForm = ({ parentPostId }) => {
29 | const history = useHistory();
30 | const [contentCharCount, setContentCharCount] = useState(0);
31 | const value = useAuth();
32 | const { username } = value.viewerQuery.data.viewer.profile;
33 |
34 | const mediaInput = useRef();
35 | const [mediaFile, setMediaFile] = useState();
36 | const validFormats = ["image/gif", "image/jpeg", "image/jpg", "image/png"];
37 |
38 | const [createPost, { loading }] = useMutation(CREATE_POST, {
39 | onCompleted: ({ createPost: { id } }) => {
40 | history.push(`/post/${id}`);
41 | },
42 | refetchQueries: () => [
43 | {
44 | query: GET_POSTS,
45 | variables: {
46 | filter: {
47 | followedBy: username,
48 | includeBlocked: false
49 | }
50 | }
51 | },
52 | {
53 | query: GET_PROFILE_CONTENT,
54 | variables: { username }
55 | }
56 | ]
57 | });
58 | const [createReply] = useMutation(CREATE_REPLY, {
59 | onCompleted: ({ createReply: { id } }) => {
60 | history.push(`/reply/${id}`);
61 | },
62 | refetchQueries: () => [
63 | { query: GET_POST, variables: { id: parentPostId } },
64 | {
65 | query: GET_PROFILE_CONTENT,
66 | variables: { username }
67 | }
68 | ]
69 | });
70 |
71 | return (
72 |
73 |
195 |
196 | );
197 | };
198 |
199 | export default CreateContentForm;
200 |
--------------------------------------------------------------------------------
/server/src/services/profiles/typeDefs.js:
--------------------------------------------------------------------------------
1 | import { gql } from "apollo-server";
2 |
3 | const typeDefs = gql`
4 | # SCALARS
5 |
6 | """
7 | The file upload type built into Apollo Server 2.0+.
8 | """
9 | scalar Upload
10 |
11 | # ENUMS
12 |
13 | """
14 | Sorting options for profile connections.
15 | """
16 | enum ProfileOrderByInput {
17 | "Order profiles ascending by username."
18 | username_ASC
19 | "Order profiles descending by username."
20 | username_DESC
21 | }
22 |
23 | # INPUTS
24 |
25 | """
26 | Provides data to create a new user profile.
27 | """
28 | input CreateProfileInput {
29 | "The new user's unique Auth0 ID."
30 | accountId: ID!
31 | "A short bio or description about the user (max. 256 characters)."
32 | description: String
33 | "The new user's full name."
34 | fullName: String
35 | "The new user's username (must be unique)."
36 | username: String!
37 | }
38 |
39 | """
40 | Provides the unique MongoDB document ID of an existing profile.
41 | """
42 | input FollowingProfileInput {
43 | "The unique profile ID of the user to be followed or unfollowed."
44 | followingProfileId: ID!
45 | }
46 |
47 | """
48 | Provides a search string to query users by usernames or full names.
49 | """
50 | input ProfileSearchInput {
51 | "The text string to search for in usernames or full names."
52 | text: String!
53 | }
54 |
55 | """
56 | Provides the unique username of an existing profile.
57 | """
58 | input ProfileWhereUniqueInput {
59 | "The unique username of the user."
60 | username: String!
61 | }
62 |
63 | """
64 | Provides data to update an existing profile.
65 | """
66 | input UpdateProfileInput {
67 | "The updated avatar with the stream, filename, mimetype, and encoding."
68 | avatar: Upload
69 | "The updated user description."
70 | description: String
71 | "The updated full name of the user."
72 | fullName: String
73 | "Whether to re-fetch GitHub profile data from the GitHub GraphQL API."
74 | github: Boolean
75 | "The updated unique username of the user."
76 | username: String
77 | }
78 |
79 | # OBJECTS
80 |
81 | extend type Account @key(fields: "id") {
82 | id: ID! @external
83 | "Metadata about the user that owns the account."
84 | profile: Profile
85 | }
86 |
87 | """
88 | Information about pagination in a connection.
89 | """
90 | type PageInfo {
91 | "The cursor to continue from when paginating forward."
92 | endCursor: String
93 | "Whether there are more items when paginating forward."
94 | hasNextPage: Boolean!
95 | "Whether there are more items when paginating backward."
96 | hasPreviousPage: Boolean!
97 | "The cursor to continue from them paginating backward."
98 | startCursor: String
99 | }
100 |
101 | type PinnableItem {
102 | "The unique GitHub ID of the repository or gist."
103 | id: ID!
104 | "The name of the repository or gist."
105 | name: String!
106 | "The description of the repository or gist."
107 | description: String
108 | "The name of the primary language of a repository's code."
109 | primaryLanguage: String
110 | "The URL for the repository or gist."
111 | url: String!
112 | }
113 |
114 | """
115 | A profile contains metadata about a specific user.
116 | """
117 | type Profile @key(fields: "id") {
118 | "The unique MongoDB document ID of the user's profile."
119 | id: ID!
120 | "The Auth0 account tied to this profile."
121 | account: Account!
122 | "The URL of the user's avatar."
123 | avatar: String
124 | "A short bio or description about the user (max. 256 characters)."
125 | description: String
126 | "Other users that the user follows."
127 | following(
128 | first: Int
129 | after: String
130 | last: Int
131 | before: String
132 | orderBy: ProfileOrderByInput
133 | ): ProfileConnection
134 | "The full name of the user."
135 | fullName: String
136 | "The URL of the user's GitHub page."
137 | githubUrl: String
138 | "The user's pinned GitHub repositories and gists."
139 | pinnedItems: [PinnableItem]
140 | "The unique username of the user."
141 | username: String!
142 | "Whether the currently logged in user follows this profile."
143 | viewerIsFollowing: Boolean
144 | }
145 |
146 | """
147 | A list of profile edges with pagination information.
148 | """
149 | type ProfileConnection {
150 | "A list of profile edges."
151 | edges: [ProfileEdge]
152 | "Information to assist with pagination."
153 | pageInfo: PageInfo!
154 | }
155 |
156 | """
157 | A single profile node with its cursor.
158 | """
159 | type ProfileEdge {
160 | "A cursor for use in pagination."
161 | cursor: ID!
162 | "A profile at the end of an edge."
163 | node: Profile!
164 | }
165 |
166 | # QUERIES & MUTATIONS
167 |
168 | extend type Query {
169 | "Retrieves a single profile by username."
170 | profile(username: String!): Profile!
171 |
172 | "Retrieves a list of profiles."
173 | profiles(
174 | after: String
175 | before: String
176 | first: Int
177 | last: Int
178 | orderBy: ProfileOrderByInput
179 | ): ProfileConnection
180 |
181 | "Performs a search of user profiles. Results are avaiable in descending order by relevance only."
182 | searchProfiles(
183 | after: String
184 | first: Int
185 | query: ProfileSearchInput!
186 | ): ProfileConnection
187 | }
188 |
189 | extend type Mutation {
190 | "Creates a new profile tied to an Auth0 account."
191 | createProfile(data: CreateProfileInput!): Profile!
192 |
193 | "Deletes a user profile."
194 | deleteProfile(where: ProfileWhereUniqueInput!): ID!
195 |
196 | "Allows one user to follow another."
197 | followProfile(
198 | data: FollowingProfileInput!
199 | where: ProfileWhereUniqueInput!
200 | ): Profile!
201 |
202 | "Allows one user to unfollow another."
203 | unfollowProfile(
204 | data: FollowingProfileInput!
205 | where: ProfileWhereUniqueInput!
206 | ): Profile!
207 |
208 | "Updates a user's profile details."
209 | updateProfile(
210 | data: UpdateProfileInput!
211 | where: ProfileWhereUniqueInput!
212 | ): Profile!
213 | }
214 | `;
215 |
216 | export default typeDefs;
217 |
--------------------------------------------------------------------------------
/client/src/pages/Settings/Account/index.js:
--------------------------------------------------------------------------------
1 | import { Box, Form, FormField, Heading, Text } from "grommet";
2 | import { useMutation } from "@apollo/client";
3 | import passwordValidator from "password-validator";
4 | import React, { useState } from "react";
5 | import validator from "validator";
6 |
7 | import { UPDATE_ACCOUNT } from "../../../graphql/mutations";
8 | import { useAuth } from "../../../context/AuthContext";
9 | import AccentButton from "../../../components/AccentButton";
10 | import DeleteAccountModal from "../../../components/DeleteAccountModal";
11 | import Loader from "../../../components/Loader";
12 | import MainLayout from "../../../layouts/MainLayout";
13 | import RequiredLabel from "../../../components/RequiredLabel";
14 |
15 | const schema = new passwordValidator();
16 | schema
17 | .is().min(8)
18 | .has().uppercase()
19 | .has().lowercase()
20 | .has().digits()
21 | .has().symbols(); // prettier-ignore
22 |
23 | const Account = () => {
24 | const { logout, viewerQuery } = useAuth();
25 | const { email: viewerEmail, id: viewerId } = viewerQuery.data.viewer;
26 |
27 | const [email, setEmail] = useState(viewerEmail);
28 | const [password, setPassword] = useState("");
29 | const [newPassword, setNewPassword] = useState("");
30 |
31 | const [updateAccountEmail, { loading }] = useMutation(UPDATE_ACCOUNT, {
32 | onCompleted: logout
33 | });
34 | const [updateAccountPassword] = useMutation(UPDATE_ACCOUNT, {
35 | onCompleted: logout
36 | });
37 |
38 | return (
39 |
40 |
50 |
55 | Account Settings
56 |
57 |
58 | Change Email
59 |
60 |
61 | After updating your email you will be redirected to log in again.
62 |
63 |
103 |
104 |
105 |
106 | Change Password
107 |
108 |
109 | After updating your password you will be redirected to log in again.
110 |
111 |
165 |
166 |
167 |
168 | Delete Account
169 |
170 |
171 | Danger zone! Click this button to permanently delete your account and
172 | all of its data:
173 |
174 |
175 |
176 |
177 | );
178 | };
179 |
180 | export default Account;
181 |
--------------------------------------------------------------------------------
/client/src/components/EditProfileForm/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | CheckBox,
5 | Form,
6 | FormField,
7 | Image,
8 | Text,
9 | TextInput
10 | } from "grommet";
11 | import { useMutation } from "@apollo/client";
12 | import React, { useEffect, useRef, useState } from "react";
13 |
14 | import { GET_VIEWER } from "../../graphql/queries";
15 | import { UPDATE_PROFILE } from "../../graphql/mutations";
16 | import { updateProfileContentAuthor } from "../../lib/updateQueries";
17 | import CharacterCountLabel from "../CharacterCountLabel";
18 | import Loader from "../Loader";
19 | import RequiredLabel from "../RequiredLabel";
20 |
21 | const EditProfileForm = ({ profileData, updateViewer }) => {
22 | const validFormats = ["image/jpeg", "image/jpg", "image/png"];
23 |
24 | const [description, setDescription] = useState(profileData.description || "");
25 | const [fullName, setFullName] = useState(profileData.fullName || "");
26 | const [username, setUsername] = useState(profileData.username);
27 | const [descCharCount, setDescCharCount] = useState(
28 | (profileData.description && profileData.description.length) || 0
29 | );
30 | const [showSavedMessage, setShowSavedMessage] = useState(false);
31 | const avatarInput = useRef();
32 | const [imageFile, setImageFile] = useState();
33 | const [githubChecked, setGithubChecked] = useState(false);
34 |
35 | const [updateProfile, { error, loading }] = useMutation(UPDATE_PROFILE, {
36 | update: (cache, { data: { updateProfile } }) => {
37 | const { viewer } = cache.readQuery({ query: GET_VIEWER });
38 | const viewerWithProfile = { ...viewer, profile: updateProfile };
39 | cache.writeQuery({
40 | query: GET_VIEWER,
41 | data: { viewer: viewerWithProfile }
42 | });
43 | updateProfileContentAuthor(cache, profileData.username, updateProfile);
44 | updateViewer(viewerWithProfile);
45 | },
46 | onCompleted: () => {
47 | setShowSavedMessage(true);
48 | }
49 | });
50 |
51 | useEffect(() => {
52 | const timer = setTimeout(() => {
53 | setShowSavedMessage(false);
54 | }, 3000);
55 | return () => {
56 | clearTimeout(timer);
57 | };
58 | });
59 |
60 | return (
61 |
210 | );
211 | };
212 |
213 | export default EditProfileForm;
214 |
--------------------------------------------------------------------------------
/server/src/scripts/auth0-deploy/tenant.yaml:
--------------------------------------------------------------------------------
1 | rules:
2 | - name: Add authorization details to token
3 | script: ./rules/Add authorization details to token.js
4 | stage: login_success
5 | enabled: true
6 | order: 1
7 | rulesConfigs: []
8 | pages: []
9 | resourceServers:
10 | - name: DevChirps GraphQL API
11 | identifier: "https://devchirps.mandiwise.com/graphql"
12 | allow_offline_access: false
13 | enforce_policies: false
14 | signing_alg: RS256
15 | skip_consent_for_verifiable_first_party_clients: true
16 | token_dialect: access_token
17 | token_lifetime: 86400
18 | token_lifetime_for_web: 7200
19 | clients:
20 | - name: Default App
21 | callbacks: []
22 | cross_origin_auth: false
23 | custom_login_page_on: true
24 | grant_types:
25 | - authorization_code
26 | - implicit
27 | - refresh_token
28 | - client_credentials
29 | is_first_party: true
30 | is_token_endpoint_ip_header_trusted: false
31 | jwt_configuration:
32 | alg: RS256
33 | lifetime_in_seconds: 36000
34 | secret_encoded: false
35 | oidc_conformant: true
36 | sso_disabled: false
37 | - name: DevChirps
38 | allowed_clients: []
39 | allowed_logout_urls:
40 | - "https://devchirps.mandiwise.com"
41 | allowed_origins:
42 | - "https://devchirps.mandiwise.com"
43 | app_type: spa
44 | callbacks:
45 | - "https://devchirps.mandiwise.com/login"
46 | client_aliases: []
47 | cross_origin_auth: false
48 | custom_login_page_on: true
49 | grant_types:
50 | - authorization_code
51 | - implicit
52 | - refresh_token
53 | is_first_party: true
54 | is_token_endpoint_ip_header_trusted: false
55 | jwt_configuration:
56 | alg: RS256
57 | lifetime_in_seconds: 36000
58 | secret_encoded: false
59 | native_social_login:
60 | apple:
61 | enabled: false
62 | oidc_conformant: true
63 | sso_disabled: false
64 | token_endpoint_auth_method: none
65 | web_origins:
66 | - "https://devchirps.mandiwise.com"
67 | - name: DevChirps GraphQL API
68 | allowed_clients: []
69 | app_type: spa
70 | callbacks: []
71 | client_aliases: []
72 | cross_origin_auth: false
73 | custom_login_page_on: true
74 | grant_types:
75 | - authorization_code
76 | - implicit
77 | - refresh_token
78 | - password
79 | - "http://auth0.com/oauth/grant-type/password-realm"
80 | is_first_party: true
81 | is_token_endpoint_ip_header_trusted: false
82 | jwt_configuration:
83 | alg: RS256
84 | lifetime_in_seconds: 36000
85 | secret_encoded: false
86 | native_social_login:
87 | apple:
88 | enabled: false
89 | oidc_conformant: true
90 | sso_disabled: false
91 | token_endpoint_auth_method: none
92 | - name: DevChirps GraphQL API (Test Application)
93 | app_type: non_interactive
94 | cross_origin_auth: false
95 | custom_login_page_on: true
96 | grant_types:
97 | - authorization_code
98 | - implicit
99 | - refresh_token
100 | - client_credentials
101 | is_first_party: true
102 | is_token_endpoint_ip_header_trusted: false
103 | jwt_configuration:
104 | alg: RS256
105 | lifetime_in_seconds: 36000
106 | secret_encoded: false
107 | oidc_conformant: true
108 | sso_disabled: false
109 | - name: DevChirps Management API
110 | app_type: non_interactive
111 | cross_origin_auth: false
112 | custom_login_page_on: true
113 | grant_types:
114 | - authorization_code
115 | - implicit
116 | - refresh_token
117 | - client_credentials
118 | is_first_party: true
119 | is_token_endpoint_ip_header_trusted: false
120 | jwt_configuration:
121 | alg: RS256
122 | lifetime_in_seconds: 36000
123 | secret_encoded: false
124 | oidc_conformant: true
125 | sso_disabled: false
126 | - name: auth0-deploy-cli-extension
127 | cross_origin_auth: false
128 | custom_login_page_on: true
129 | grant_types:
130 | - authorization_code
131 | - implicit
132 | - refresh_token
133 | - client_credentials
134 | is_first_party: true
135 | is_token_endpoint_ip_header_trusted: false
136 | jwt_configuration:
137 | alg: RS256
138 | lifetime_in_seconds: 36000
139 | secret_encoded: false
140 | oidc_conformant: true
141 | sso_disabled: false
142 | databases:
143 | - name: Username-Password-Authentication
144 | strategy: auth0
145 | enabled_clients:
146 | - DevChirps
147 | - DevChirps Management API
148 | - DevChirps GraphQL API (Test Application)
149 | - auth0-deploy-cli-extension
150 | - Default App
151 | - DevChirps GraphQL API
152 | is_domain_connection: false
153 | options:
154 | mfa:
155 | active: true
156 | return_enroll_settings: true
157 | passwordPolicy: good
158 | strategy_version: 2
159 | brute_force_protection: true
160 | realms:
161 | - Username-Password-Authentication
162 | connections:
163 | - name: github
164 | strategy: github
165 | enabled_clients:
166 | - DevChirps
167 | - auth0-deploy-cli-extension
168 | is_domain_connection: false
169 | options:
170 | client_id: @@GH_CLIENT_ID@@
171 | client_secret: @@GH_CLIENT_SECRET@@
172 | read_user: true
173 | profile: true
174 | email: false
175 | follow: false
176 | public_repo: false
177 | repo: false
178 | repo_deployment: false
179 | repo_status: false
180 | delete_repo: false
181 | notifications: false
182 | gist: false
183 | read_repo_hook: false
184 | write_repo_hook: false
185 | admin_repo_hook: false
186 | read_org: false
187 | write_org: false
188 | admin_org: false
189 | read_public_key: false
190 | write_public_key: false
191 | admin_public_key: false
192 | scope:
193 | - "read:user"
194 | - name: google-oauth2
195 | strategy: google-oauth2
196 | enabled_clients: []
197 | is_domain_connection: false
198 | options:
199 | email: true
200 | profile: true
201 | scope:
202 | - email
203 | - profile
204 | tenant:
205 | enabled_locales:
206 | - en
207 | flags:
208 | new_universal_login_experience_enabled: false
209 | universal_login: false
210 | disable_clickjack_protection_headers: false
211 | picture_url: "https://i.imgur.com/gsLdLSC.png"
212 | universal_login:
213 | colors:
214 | page_background: "#000000"
215 | primary: "#7d4cdb"
216 | emailProvider: {}
217 | emailTemplates: []
218 | clientGrants:
219 | - client_id: DevChirps GraphQL API (Test Application)
220 | audience: "https://devchirps.mandiwise.com/graphql"
221 | scope: []
222 | - client_id: DevChirps Management API
223 | audience: "https://devchirps.auth0.com/api/v2/"
224 | scope:
225 | - "read:users"
226 | - "update:users"
227 | - "delete:users"
228 | - "create:users"
229 | - "read:users_app_metadata"
230 | - "update:users_app_metadata"
231 | - "delete:users_app_metadata"
232 | - "create:users_app_metadata"
233 | - "read:user_idp_tokens"
234 | guardianFactors:
235 | - name: duo
236 | enabled: false
237 | - name: email
238 | enabled: false
239 | - name: otp
240 | enabled: false
241 | - name: push-notification
242 | enabled: false
243 | - name: sms
244 | enabled: false
245 | guardianFactorProviders: []
246 | guardianFactorTemplates: []
247 | roles: []
248 | branding: {}
249 | prompts: {}
250 |
--------------------------------------------------------------------------------
/server/src/services/content/typeDefs.js:
--------------------------------------------------------------------------------
1 | import { gql } from "apollo-server";
2 |
3 | const typeDefs = gql`
4 | # SCALARS
5 |
6 | """
7 | An ISO 8601-encoded UTC date string.
8 | """
9 | scalar DateTime
10 |
11 | """
12 | The file upload type built into Apollo Server 2.0+.
13 | """
14 | scalar Upload
15 |
16 | # ENUMS
17 |
18 | """
19 | Sorting options for post connections.
20 | """
21 | enum PostOrderByInput {
22 | "Order posts ascending by creation time."
23 | createdAt_ASC
24 | "Order posts descending by creation time."
25 | createdAt_DESC
26 | }
27 |
28 | """
29 | Provides a search string to query posts by text in their body content.
30 | """
31 | input PostSearchInput {
32 | "The text string to search for in the post content."
33 | text: String!
34 | }
35 |
36 | """
37 | Sorting options for reply connections.
38 | """
39 | enum ReplyOrderByInput {
40 | "Order replies ascending by creation time."
41 | createdAt_ASC
42 | "Order replies descending by creation time."
43 | createdAt_DESC
44 | }
45 |
46 | # INTERFACES
47 |
48 | """
49 | Specifies common fields for posts and replies.
50 | """
51 | interface Content {
52 | "The unique MongoDB document ID of the content."
53 | id: ID!
54 | "The profile of the user who authored the content."
55 | author: Profile!
56 | "The date and time the content was created."
57 | createdAt: DateTime!
58 | "Whether the content is blocked."
59 | isBlocked: Boolean
60 | "The URL of a media file associated with the content."
61 | media: String
62 | "The body content (max. 256 characters)."
63 | text: String!
64 | }
65 |
66 | # INPUTS
67 |
68 | """
69 | Provides the unique ID of an existing piece of content.
70 | """
71 | input ContentWhereUniqueInput {
72 | "The unique MongoDB document ID associated with the content."
73 | id: ID!
74 | }
75 |
76 | """
77 | Provides data to create a post.
78 | """
79 | input CreatePostInput {
80 | "The post's media with the stream, filename, mimetype, and encoding."
81 | media: Upload
82 | "The body content of the post (max. 256 characters)."
83 | text: String!
84 | "The unique username of the user who authored the post."
85 | username: String!
86 | }
87 |
88 | """
89 | Provides data to create a reply to a post.
90 | """
91 | input CreateReplyInput {
92 | "The reply's media with the stream, filename, mimetype, and encoding."
93 | media: Upload
94 | "The unique MongoDB document ID if the parent post."
95 | postId: ID!
96 | "The body content of the reply (max. 256 characters)."
97 | text: String!
98 | "The unique username of the user who authored the reply."
99 | username: String!
100 | }
101 |
102 | """
103 | Provides a filter on which posts may be queried.
104 | """
105 | input PostWhereInput {
106 | "The unique username of the user viewing posts by users they follow (including their own)."
107 | followedBy: String
108 | "Whether to include posts that have been blocked by a moderator (default is true)."
109 | includeBlocked: Boolean
110 | }
111 |
112 | """
113 | Provides a filter on which replies may be queried.
114 | """
115 | input ReplyWhereInput {
116 | "The unique username of the user who sent the replies."
117 | from: String
118 | "The unique username of the user who received the replies."
119 | to: String
120 | }
121 |
122 | # OBJECTS
123 |
124 | """
125 | Information about pagination in a connection.
126 | """
127 | type PageInfo {
128 | "The cursor to continue from when paginating forward."
129 | endCursor: String
130 | "Whether there are more items when paginating forward."
131 | hasNextPage: Boolean!
132 | "Whether there are more items when paginating backward."
133 | hasPreviousPage: Boolean!
134 | "The cursor to continue from them paginating backward."
135 | startCursor: String
136 | }
137 |
138 | """
139 | A post contains content authored by a user.
140 | """
141 | type Post implements Content {
142 | "The unique MongoDB document ID of the post."
143 | id: ID!
144 | "The profile of the user who authored the post."
145 | author: Profile!
146 | "The date and time the post was created."
147 | createdAt: DateTime!
148 | "Whether the post is blocked."
149 | isBlocked: Boolean
150 | "The URL of a media file associated with the content."
151 | media: String
152 | "Replies to this post."
153 | replies(
154 | after: String
155 | before: String
156 | first: Int
157 | last: Int
158 | orderBy: ReplyOrderByInput
159 | ): ReplyConnection
160 | "The body content of the post (max. 256 characters)."
161 | text: String!
162 | }
163 |
164 | """
165 | A list of post edges with pagination information.
166 | """
167 | type PostConnection {
168 | "A list of post edges."
169 | edges: [PostEdge]
170 | "Information to assist with pagination."
171 | pageInfo: PageInfo!
172 | }
173 |
174 | """
175 | A single post node with its cursor.
176 | """
177 | type PostEdge {
178 | "A cursor for use in pagination."
179 | cursor: ID!
180 | "A post at the end of an edge."
181 | node: Post!
182 | }
183 |
184 | extend type Profile @key(fields: "id") {
185 | id: ID! @external
186 | "A list of posts written by the user."
187 | posts(
188 | after: String
189 | before: String
190 | first: Int
191 | last: Int
192 | orderBy: PostOrderByInput
193 | ): PostConnection
194 | "A list of replies written by the user."
195 | replies(
196 | after: String
197 | before: String
198 | first: Int
199 | last: Int
200 | orderBy: ReplyOrderByInput
201 | ): ReplyConnection
202 | }
203 |
204 | """
205 | A reply contains content that is a response to another post.
206 | """
207 | type Reply implements Content {
208 | "The unique MongoDB document ID of the reply."
209 | id: ID!
210 | "The profile of the user who authored the reply."
211 | author: Profile!
212 | "The date and time the reply was created."
213 | createdAt: DateTime!
214 | "Whether the reply is blocked."
215 | isBlocked: Boolean
216 | "The URL of a media file associated with the content."
217 | media: String
218 | "The parent post of the reply."
219 | post: Post
220 | "The author of the parent post of the reply."
221 | postAuthor: Profile
222 | "The body content of the reply (max. 256 characters)."
223 | text: String!
224 | }
225 |
226 | """
227 | A list of reply edges with pagination information.
228 | """
229 | type ReplyConnection {
230 | "A list of reply edges."
231 | edges: [ReplyEdge]
232 | "Information to assist with pagination."
233 | pageInfo: PageInfo!
234 | }
235 |
236 | """
237 | A single reply node with its cursor.
238 | """
239 | type ReplyEdge {
240 | "A cursor for use in pagination."
241 | cursor: ID!
242 | "A reply at the end of an edge."
243 | node: Reply!
244 | }
245 |
246 | extend type Query {
247 | "Retrieves a single post by MongoDB document ID."
248 | post(id: ID!): Post!
249 |
250 | "Retrieves a list of posts."
251 | posts(
252 | after: String
253 | before: String
254 | first: Int
255 | last: Int
256 | orderBy: PostOrderByInput
257 | filter: PostWhereInput
258 | ): PostConnection
259 |
260 | "Retrieves a single reply by MongoDB document ID."
261 | reply(id: ID!): Reply!
262 |
263 | "Retrieves a list of replies."
264 | replies(
265 | after: String
266 | before: String
267 | first: Int
268 | last: Int
269 | orderBy: ReplyOrderByInput
270 | filter: ReplyWhereInput!
271 | ): ReplyConnection
272 |
273 | "Performs a search of posts. Results are available in descending order by relevance only."
274 | searchPosts(
275 | after: String
276 | first: Int
277 | query: PostSearchInput!
278 | ): PostConnection
279 | }
280 |
281 | extend type Mutation {
282 | "Creates a new post."
283 | createPost(data: CreatePostInput!): Post!
284 |
285 | "Deletes a post."
286 | deletePost(where: ContentWhereUniqueInput!): ID!
287 |
288 | "Creates a new reply to a post."
289 | createReply(data: CreateReplyInput!): Reply!
290 |
291 | "Deletes a reply to a post."
292 | deleteReply(where: ContentWhereUniqueInput!): ID!
293 |
294 | "Toggles the current blocked state of the post."
295 | togglePostBlock(where: ContentWhereUniqueInput!): Post!
296 |
297 | "Toggles the current blocked state of the reply."
298 | toggleReplyBlock(where: ContentWhereUniqueInput!): Reply!
299 | }
300 | `;
301 |
302 | export default typeDefs;
303 |
--------------------------------------------------------------------------------