Skip to content
This repository has been archived by the owner on Aug 13, 2024. It is now read-only.

Commit

Permalink
init
Browse files Browse the repository at this point in the history
  • Loading branch information
highestblue committed Mar 14, 2022
0 parents commit 83d18a1
Show file tree
Hide file tree
Showing 25 changed files with 2,481 additions and 0 deletions.
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org

# top-most EditorConfig file
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
9 changes: 9 additions & 0 deletions .env-example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CLIENT_ID=
CLIENT_SECRET=
SLUG=
PORT=
ENVIRONMENT=thinkific.com
MONGODB_URI=
APP_URL=
APP_FRAMES_REMOTE_URL=https://cdn.thinkific.com/assets/app-frames/remote/latest/index.js
PROTOCOL=https
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
.env

# yalc
.yalc
yalc.*
3 changes: 3 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"singleQuote": true
}
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Thinkific Embedded App NodeJS Example

This is an example of a NodeJS application to set up an embedded app for Thinkific.

## Prerequisites

You will need the credentials for your app created in Thinkific Platform.

- client_id: App's client id;
- redirect_uri: App's registered callback uri. For example, this app uses `{app_url}/install/callback`.

Additionally, you will need a MongoDB database.

## Description

This project takes you through Thinkific's OAuth app install flow then renders a SSR app inside Thinkific's app details page i.e. _/manage/apps/{slug}_. The app is built to be compatible for the embedded app experience i.e. to be iframed into Thinkific app details page.

It consumes a couple of Thinkific's packages, namely Toga (UI library) and App Frames (client-side library). The use of former is optional, but the latter must be used in order to interact with the host environment for certain behaviours e.g. dispatch a toast message.


## Project setup

Create `.env` file

```
cp .env-example .env
```

and populate the `CLIENT_ID` with your app's client_id

```
CLIENT_ID=
```

You must also populate the rest of the environment variables according to your app's needs:

```
PORT=
MONGODB_URI=
APP_URL=
```

Install dependencies:
```
npm install
```

Run the app:
```
npm run dev
```
55 changes: 55 additions & 0 deletions controllers/install.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const axios = require('axios');

const User = require('../models/user');

require('dotenv').config();

const { codeChallenge } = require('../utils/utils');

exports.index = (req, res, next) => {
const { subdomain } = req.query;
const callbackUrl = `${process.env.APP_URL}/install/callback`;
const authorizeUrl = `${process.env.PROTOCOL}://${subdomain}.${
process.env.ENVIRONMENT
}/oauth2/authorize?client_id=${
process.env.CLIENT_ID
}&response_type=code&redirect_uri=${callbackUrl}&code_challenge=${codeChallenge(
codeVerifier
)}&code_challenge_method=S256`;
res.redirect(authorizeUrl);
};

exports.callback = async (req, res, next) => {
const { code, subdomain } = req.query;
const tokenUrl = `${process.env.PROTOCOL}://${subdomain}.${process.env.ENVIRONMENT}/oauth2/token`;
const options = {
grant_type: 'authorization_code',
code_verifier: codeVerifier,
code,
};
const authParams = {
auth: {
username: process.env.CLIENT_ID,
},
};

try {
const token = await axios.post(tokenUrl, options, authParams);
const { access_token: accessToken, gid } = token.data;
let user = await User.findOne({ subdomain });

if (!user) {
user = await User.create({ subdomain, gid, accessToken });
}

const appSubviewUrl = `${process.env.PROTOCOL}://${subdomain}.${process.env.ENVIRONMENT}/manage/apps/${process.env.SLUG}#embedded-app`;

if (user) {
res.redirect(appSubviewUrl);
} else {
res.send(`<h1>User not found.</h1>`);
}
} catch (err) {
console.error(err);
}
};
24 changes: 24 additions & 0 deletions controllers/root.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const User = require('../models/user');
const { verifyHmacSignature } = require('../utils/utils');

exports.index = async (req, res, next) => {
const { hmac, subdomain, tgid, timestamp } = req.query;

if (!verifyHmacSignature(hmac, subdomain, tgid, timestamp)) {
res.send(`<h1>Invalid HMAC</h1>`);
}

try {
const user = await User.findOne({ subdomain }).orFail(
new Error('User not found')
);

if (user) {
res.render('root/index', { subdomain, userId: user._id });
} else {
res.send(`<h1>User not found.</h1>`);
}
} catch (err) {
console.error(err);
}
};
8 changes: 8 additions & 0 deletions graphql/resolvers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const { addTask, deleteTask, task, tasks } = require('./resolvers/task');

module.exports = {
addTask,
deleteTask,
task,
tasks,
};
59 changes: 59 additions & 0 deletions graphql/resolvers/task.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const Task = require('../../models/task');

async function task(args, _) {
const { id } = args;

try {
const task = await Task.findById(id).orFail(new Error('Task not found'));

return task;
} catch (err) {
console.error(err);
return err;
}
}

async function tasks(args, _) {
const { userId } = args;

try {
const tasks = await Task.find({ userId });
return tasks;
} catch (err) {
console.error(err);
return err;
}
}

async function addTask(args, _) {
const { description, userId } = args.input;

try {
const task = await Task.create({ description, userId });
return task;
} catch (err) {
console.error(err);
return err;
}
}

async function deleteTask(args, _) {
const { id } = args;

try {
const task = await Task.findByIdAndDelete(id).orFail(
new Error('Task not found')
);
return task;
} catch (err) {
console.error(err);
return err;
}
}

module.exports = {
addTask,
deleteTask,
task,
tasks,
};
29 changes: 29 additions & 0 deletions graphql/schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const { buildSchema } = require('graphql');

module.exports = buildSchema(`
type Task {
_id: ID!
description: String!
userId: ID!
}
input TaskInput {
description: String!
userId: ID!
}
type RootQuery {
task(id: ID!): Task!
tasks(userId: ID!): [Task]!
}
type RootMutation {
addTask(input: TaskInput!): Task!
deleteTask(id: ID!): Task!
}
schema {
query: RootQuery
mutation: RootMutation
}
`);
53 changes: 53 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const path = require('path');
const express = require('express');
const crypto = require('crypto');
const bodyParser = require('body-parser');
const mongoose = require('mongoose');
const { graphqlHTTP } = require('express-graphql');
const cors = require('cors');

const graphqlSchema = require('./graphql/schema');
const graphqlResolver = require('./graphql/resolvers');

const rootRoutes = require('./routes/root');
const installRoutes = require('./routes/install');

const { base64EncodeUrlSafe } = require('./utils/utils');

require('dotenv').config();

const app = express();

global.codeVerifier = base64EncodeUrlSafe(crypto.randomBytes(32));

app.set('view engine', 'ejs');

app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, 'public')));

app.use(cors());
app.use('/', rootRoutes);
app.use(
'/graphql',
graphqlHTTP({
schema: graphqlSchema,
rootValue: graphqlResolver,
graphiql: true,
})
);
app.use('/install', installRoutes);

app.use((req, res, next) => {
res.status(404).render('404', { pageTitle: 'Page not found', path: '/404' });
});

mongoose
.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then((_) => {
app.listen(process.env.PORT || 3001);
})
.catch((err) => console.error(err));
17 changes: 17 additions & 0 deletions models/task.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const mongoose = require('mongoose');

const taskSchema = mongoose.Schema(
{
description: {
type: String,
required: true,
},
userId: {
type: mongoose.Types.ObjectId,
ref: 'user',
},
},
{ timestamps: true }
);

module.exports = mongoose.model('task', taskSchema);
21 changes: 21 additions & 0 deletions models/user.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const mongoose = require('mongoose');

const userSchema = mongoose.Schema(
{
accessToken: {
type: String,
required: true,
},
gid: {
type: String,
required: true,
},
subdomain: {
type: String,
required: true,
},
},
{ timestamps: true }
);

module.exports = mongoose.model('user', userSchema);
Loading

0 comments on commit 83d18a1

Please sign in to comment.