@canva/cli 0.0.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +48 -0
- package/README.md +206 -0
- package/cli.js +566 -0
- package/package.json +30 -0
- package/templates/base/backend/database/database.ts +42 -0
- package/templates/base/backend/routers/auth.ts +285 -0
- package/templates/base/declarations/declarations.d.ts +29 -0
- package/templates/base/eslint.config.mjs +309 -0
- package/templates/base/package.json +83 -0
- package/templates/base/scripts/ssl/ssl.ts +131 -0
- package/templates/base/scripts/start/app_runner.ts +164 -0
- package/templates/base/scripts/start/context.ts +165 -0
- package/templates/base/scripts/start/start.ts +35 -0
- package/templates/base/styles/components.css +38 -0
- package/templates/base/tsconfig.json +54 -0
- package/templates/base/utils/backend/base_backend/create.ts +104 -0
- package/templates/base/utils/backend/jwt_middleware/index.ts +1 -0
- package/templates/base/utils/backend/jwt_middleware/jwt_middleware.ts +229 -0
- package/templates/base/utils/backend/jwt_middleware/tests/jwt_middleware.tests.ts +630 -0
- package/templates/base/webpack.config.cjs +270 -0
- package/templates/common/.env.template +6 -0
- package/templates/common/.gitignore.template +9 -0
- package/templates/common/LICENSE.md +48 -0
- package/templates/common/README.md +250 -0
- package/templates/common/jest.config.mjs +8 -0
- package/templates/dam/backend/database/database.ts +42 -0
- package/templates/dam/backend/routers/auth.ts +285 -0
- package/templates/dam/backend/routers/dam.ts +86 -0
- package/templates/dam/backend/server.ts +65 -0
- package/templates/dam/declarations/declarations.d.ts +29 -0
- package/templates/dam/eslint.config.mjs +309 -0
- package/templates/dam/package.json +90 -0
- package/templates/dam/scripts/ssl/ssl.ts +131 -0
- package/templates/dam/scripts/start/app_runner.ts +164 -0
- package/templates/dam/scripts/start/context.ts +165 -0
- package/templates/dam/scripts/start/start.ts +35 -0
- package/templates/dam/src/adapter.ts +44 -0
- package/templates/dam/src/app.tsx +147 -0
- package/templates/dam/src/config.ts +95 -0
- package/templates/dam/src/index.css +10 -0
- package/templates/dam/src/index.tsx +22 -0
- package/templates/dam/tsconfig.json +54 -0
- package/templates/dam/utils/backend/base_backend/create.ts +104 -0
- package/templates/dam/utils/backend/jwt_middleware/index.ts +1 -0
- package/templates/dam/utils/backend/jwt_middleware/jwt_middleware.ts +229 -0
- package/templates/dam/utils/backend/jwt_middleware/tests/jwt_middleware.tests.ts +630 -0
- package/templates/dam/webpack.config.cjs +270 -0
- package/templates/gen_ai/README.md +27 -0
- package/templates/gen_ai/backend/database/database.ts +42 -0
- package/templates/gen_ai/backend/routers/auth.ts +285 -0
- package/templates/gen_ai/backend/routers/image.ts +234 -0
- package/templates/gen_ai/backend/server.ts +56 -0
- package/templates/gen_ai/declarations/declarations.d.ts +29 -0
- package/templates/gen_ai/eslint.config.mjs +309 -0
- package/templates/gen_ai/package.json +92 -0
- package/templates/gen_ai/scripts/ssl/ssl.ts +131 -0
- package/templates/gen_ai/scripts/start/app_runner.ts +164 -0
- package/templates/gen_ai/scripts/start/context.ts +165 -0
- package/templates/gen_ai/scripts/start/start.ts +35 -0
- package/templates/gen_ai/src/api/api.ts +228 -0
- package/templates/gen_ai/src/api/index.ts +1 -0
- package/templates/gen_ai/src/app.tsx +13 -0
- package/templates/gen_ai/src/components/app_error.tsx +18 -0
- package/templates/gen_ai/src/components/footer.messages.ts +53 -0
- package/templates/gen_ai/src/components/footer.tsx +157 -0
- package/templates/gen_ai/src/components/image_grid.tsx +96 -0
- package/templates/gen_ai/src/components/index.ts +8 -0
- package/templates/gen_ai/src/components/loading_results.tsx +169 -0
- package/templates/gen_ai/src/components/logged_in_status.tsx +44 -0
- package/templates/gen_ai/src/components/prompt_input.messages.ts +14 -0
- package/templates/gen_ai/src/components/prompt_input.tsx +149 -0
- package/templates/gen_ai/src/components/remaining_credits.tsx +75 -0
- package/templates/gen_ai/src/components/report_box.tsx +53 -0
- package/templates/gen_ai/src/config.ts +21 -0
- package/templates/gen_ai/src/context/app_context.tsx +174 -0
- package/templates/gen_ai/src/context/context.messages.ts +41 -0
- package/templates/gen_ai/src/context/index.ts +2 -0
- package/templates/gen_ai/src/context/use_app_context.ts +17 -0
- package/templates/gen_ai/src/index.tsx +31 -0
- package/templates/gen_ai/src/pages/error.tsx +41 -0
- package/templates/gen_ai/src/pages/generate.tsx +9 -0
- package/templates/gen_ai/src/pages/index.ts +3 -0
- package/templates/gen_ai/src/pages/results.tsx +31 -0
- package/templates/gen_ai/src/routes/index.ts +1 -0
- package/templates/gen_ai/src/routes/routes.tsx +26 -0
- package/templates/gen_ai/src/services/auth.tsx +31 -0
- package/templates/gen_ai/src/services/index.ts +1 -0
- package/templates/gen_ai/src/utils/index.ts +1 -0
- package/templates/gen_ai/src/utils/obscenity_filter.ts +33 -0
- package/templates/gen_ai/styles/components.css +38 -0
- package/templates/gen_ai/styles/utils.css +3 -0
- package/templates/gen_ai/tsconfig.json +54 -0
- package/templates/gen_ai/utils/backend/base_backend/create.ts +104 -0
- package/templates/gen_ai/utils/backend/jwt_middleware/index.ts +1 -0
- package/templates/gen_ai/utils/backend/jwt_middleware/jwt_middleware.ts +229 -0
- package/templates/gen_ai/utils/backend/jwt_middleware/tests/jwt_middleware.tests.ts +630 -0
- package/templates/gen_ai/webpack.config.cjs +270 -0
- package/templates/hello_world/declarations/declarations.d.ts +29 -0
- package/templates/hello_world/eslint.config.mjs +309 -0
- package/templates/hello_world/package.json +73 -0
- package/templates/hello_world/scripts/ssl/ssl.ts +131 -0
- package/templates/hello_world/scripts/start/app_runner.ts +164 -0
- package/templates/hello_world/scripts/start/context.ts +165 -0
- package/templates/hello_world/scripts/start/start.ts +35 -0
- package/templates/hello_world/src/app.tsx +41 -0
- package/templates/hello_world/src/index.tsx +22 -0
- package/templates/hello_world/styles/components.css +38 -0
- package/templates/hello_world/tsconfig.json +54 -0
- package/templates/hello_world/webpack.config.cjs +270 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
require("dotenv").config();
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const TerserPlugin = require("terser-webpack-plugin");
|
|
4
|
+
const { DefinePlugin, optimize } = require("webpack");
|
|
5
|
+
const chalk = require("chalk");
|
|
6
|
+
const { transform } = require("@formatjs/ts-transformer");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {string} [options.appEntry=./src/index.tsx]
|
|
12
|
+
* @param {string} [options.backendHost]
|
|
13
|
+
* @param {Object} [options.devConfig]
|
|
14
|
+
* @param {number} [options.devConfig.port]
|
|
15
|
+
* @param {boolean} [options.devConfig.enableHmr]
|
|
16
|
+
* @param {boolean} [options.devConfig.enableHttps]
|
|
17
|
+
* @param {string} [options.devConfig.appOrigin]
|
|
18
|
+
* @param {string} [options.devConfig.appId] - Deprecated in favour of appOrigin
|
|
19
|
+
* @param {string} [options.devConfig.certFile]
|
|
20
|
+
* @param {string} [options.devConfig.keyFile]
|
|
21
|
+
* @returns {Object}
|
|
22
|
+
*/
|
|
23
|
+
function buildConfig({
|
|
24
|
+
devConfig,
|
|
25
|
+
appEntry = path.join(process.cwd(), "src", "index.tsx"),
|
|
26
|
+
backendHost = process.env.CANVA_BACKEND_HOST,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const mode = devConfig ? "development" : "production";
|
|
29
|
+
|
|
30
|
+
if (!backendHost) {
|
|
31
|
+
console.error(
|
|
32
|
+
chalk.redBright.bold("BACKEND_HOST is undefined."),
|
|
33
|
+
`Refer to "Customizing the backend host" in the README.md for more information.`,
|
|
34
|
+
);
|
|
35
|
+
process.exit(-1);
|
|
36
|
+
} else if (backendHost.includes("localhost") && mode === "production") {
|
|
37
|
+
console.error(
|
|
38
|
+
chalk.redBright.bold(
|
|
39
|
+
"BACKEND_HOST should not be set to localhost for production builds!",
|
|
40
|
+
),
|
|
41
|
+
`Refer to "Customizing the backend host" in the README.md for more information.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
mode,
|
|
47
|
+
context: path.resolve(process.cwd(), "./"),
|
|
48
|
+
entry: {
|
|
49
|
+
app: appEntry,
|
|
50
|
+
},
|
|
51
|
+
target: "web",
|
|
52
|
+
resolve: {
|
|
53
|
+
alias: {
|
|
54
|
+
assets: path.resolve(process.cwd(), "assets"),
|
|
55
|
+
utils: path.resolve(process.cwd(), "utils"),
|
|
56
|
+
styles: path.resolve(process.cwd(), "styles"),
|
|
57
|
+
src: path.resolve(process.cwd(), "src"),
|
|
58
|
+
},
|
|
59
|
+
extensions: [".ts", ".tsx", ".js", ".css", ".svg", ".woff", ".woff2"],
|
|
60
|
+
},
|
|
61
|
+
infrastructureLogging: {
|
|
62
|
+
level: "none",
|
|
63
|
+
},
|
|
64
|
+
module: {
|
|
65
|
+
rules: [
|
|
66
|
+
{
|
|
67
|
+
test: /\.tsx?$/,
|
|
68
|
+
exclude: /node_modules/,
|
|
69
|
+
use: [
|
|
70
|
+
{
|
|
71
|
+
loader: "ts-loader",
|
|
72
|
+
options: {
|
|
73
|
+
transpileOnly: true,
|
|
74
|
+
getCustomTransformers() {
|
|
75
|
+
return {
|
|
76
|
+
before: [
|
|
77
|
+
transform({
|
|
78
|
+
overrideIdFn: "[sha512:contenthash:base64:6]",
|
|
79
|
+
}),
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
test: /\.css$/,
|
|
89
|
+
exclude: /node_modules/,
|
|
90
|
+
use: [
|
|
91
|
+
"style-loader",
|
|
92
|
+
{
|
|
93
|
+
loader: "css-loader",
|
|
94
|
+
options: {
|
|
95
|
+
modules: true,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
loader: "postcss-loader",
|
|
100
|
+
options: {
|
|
101
|
+
postcssOptions: {
|
|
102
|
+
plugins: [require("cssnano")({ preset: "default" })],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
test: /\.(png|jpg|jpeg)$/i,
|
|
110
|
+
type: "asset/inline",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
test: /\.(woff|woff2)$/,
|
|
114
|
+
type: "asset/inline",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
test: /\.svg$/,
|
|
118
|
+
oneOf: [
|
|
119
|
+
{
|
|
120
|
+
issuer: /\.[jt]sx?$/,
|
|
121
|
+
resourceQuery: /react/, // *.svg?react
|
|
122
|
+
use: ["@svgr/webpack", "url-loader"],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
type: "asset/resource",
|
|
126
|
+
parser: {
|
|
127
|
+
dataUrlCondition: {
|
|
128
|
+
maxSize: 200,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
test: /\.css$/,
|
|
136
|
+
include: /node_modules/,
|
|
137
|
+
use: [
|
|
138
|
+
"style-loader",
|
|
139
|
+
"css-loader",
|
|
140
|
+
{
|
|
141
|
+
loader: "postcss-loader",
|
|
142
|
+
options: {
|
|
143
|
+
postcssOptions: {
|
|
144
|
+
plugins: [require("cssnano")({ preset: "default" })],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
optimization: {
|
|
153
|
+
minimizer: [
|
|
154
|
+
new TerserPlugin({
|
|
155
|
+
terserOptions: {
|
|
156
|
+
format: {
|
|
157
|
+
// Turned on because emoji and regex is not minified properly using default
|
|
158
|
+
// https://github.com/facebook/create-react-app/issues/2488
|
|
159
|
+
ascii_only: true,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
output: {
|
|
166
|
+
filename: `[name].js`,
|
|
167
|
+
path: path.resolve(process.cwd(), "dist"),
|
|
168
|
+
clean: true,
|
|
169
|
+
},
|
|
170
|
+
plugins: [
|
|
171
|
+
new DefinePlugin({
|
|
172
|
+
BACKEND_HOST: JSON.stringify(backendHost),
|
|
173
|
+
}),
|
|
174
|
+
// Apps can only submit a single JS file via the developer portal
|
|
175
|
+
new optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
|
|
176
|
+
],
|
|
177
|
+
...buildDevConfig(devConfig),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
*
|
|
183
|
+
* @param {Object} [options]
|
|
184
|
+
* @param {number} [options.port]
|
|
185
|
+
* @param {boolean} [options.enableHmr]
|
|
186
|
+
* @param {boolean} [options.enableHttps]
|
|
187
|
+
* @param {string} [options.appOrigin]
|
|
188
|
+
* @param {string} [options.appId] - Deprecated in favour of appOrigin
|
|
189
|
+
* @param {string} [options.certFile]
|
|
190
|
+
* @param {string} [options.keyFile]
|
|
191
|
+
* @returns {Object|null}
|
|
192
|
+
*/
|
|
193
|
+
function buildDevConfig(options) {
|
|
194
|
+
if (!options) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { port, enableHmr, appOrigin, appId, enableHttps, certFile, keyFile } =
|
|
199
|
+
options;
|
|
200
|
+
|
|
201
|
+
let devServer = {
|
|
202
|
+
server: enableHttps
|
|
203
|
+
? {
|
|
204
|
+
type: "https",
|
|
205
|
+
options: {
|
|
206
|
+
cert: certFile,
|
|
207
|
+
key: keyFile,
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
: "http",
|
|
211
|
+
host: "localhost",
|
|
212
|
+
historyApiFallback: {
|
|
213
|
+
rewrites: [{ from: /^\/$/, to: "/app.js" }],
|
|
214
|
+
},
|
|
215
|
+
port,
|
|
216
|
+
client: {
|
|
217
|
+
logging: "verbose",
|
|
218
|
+
},
|
|
219
|
+
static: {
|
|
220
|
+
directory: path.resolve(process.cwd(), "assets"),
|
|
221
|
+
publicPath: "/assets",
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (enableHmr && appOrigin) {
|
|
226
|
+
devServer = {
|
|
227
|
+
...devServer,
|
|
228
|
+
allowedHosts: new URL(appOrigin).hostname,
|
|
229
|
+
headers: {
|
|
230
|
+
"Access-Control-Allow-Origin": appOrigin,
|
|
231
|
+
"Access-Control-Allow-Credentials": "true",
|
|
232
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
} else if (enableHmr && appId) {
|
|
236
|
+
// Deprecated - App ID should not be used to configure HMR in the future and can be safely removed
|
|
237
|
+
// after a few months.
|
|
238
|
+
|
|
239
|
+
console.warn(
|
|
240
|
+
"Enabling Hot Module Replacement (HMR) with an App ID is deprecated, please see the README.md on how to update.",
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const appDomain = `app-${appId.toLowerCase().trim()}.canva-apps.com`;
|
|
244
|
+
devServer = {
|
|
245
|
+
...devServer,
|
|
246
|
+
allowedHosts: appDomain,
|
|
247
|
+
headers: {
|
|
248
|
+
"Access-Control-Allow-Origin": `https://${appDomain}`,
|
|
249
|
+
"Access-Control-Allow-Credentials": "true",
|
|
250
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
} else {
|
|
254
|
+
if (enableHmr && !appOrigin) {
|
|
255
|
+
console.warn(
|
|
256
|
+
"Attempted to enable Hot Module Replacement (HMR) without configuring App Origin... Disabling HMR.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
devServer.webSocketServer = false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
devtool: "source-map",
|
|
264
|
+
devServer,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = () => buildConfig();
|
|
269
|
+
|
|
270
|
+
module.exports.buildConfig = buildConfig;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## Generative AI Template
|
|
2
|
+
|
|
3
|
+
This template captures best practices for improving user experience in your application.
|
|
4
|
+
|
|
5
|
+
### State Management
|
|
6
|
+
|
|
7
|
+
In this template, we've set up state management using `React Context`. It's just one way to do it, not a strict rule. If your app gets more complicated, you might want to check out other options like `Redux` or `MobX`.
|
|
8
|
+
|
|
9
|
+
### Routing
|
|
10
|
+
|
|
11
|
+
As your application evolves, you may find the need for routing to manage multiple views or pages. In this template, we've integrated React Router to illustrate how routing can facilitate seamless navigation between various components.
|
|
12
|
+
|
|
13
|
+
### Loading state
|
|
14
|
+
|
|
15
|
+
Creating AI assets can be time-consuming, often resulting in users facing extended waiting periods. Incorporating placeholders, a loading bar, and a message indicating the expected wait time can help alleviate the perceived wait time. We highly encourage adopting this approach and customizing it to suit your specific use case.
|
|
16
|
+
|
|
17
|
+
### Obscenity filter
|
|
18
|
+
|
|
19
|
+
In this template, we've included a basic obscenity filter to stop users from creating offensive or harmful content. However, you might need additional filters or checks after content generation to ensure it meets your standards.
|
|
20
|
+
|
|
21
|
+
### Backend
|
|
22
|
+
|
|
23
|
+
This template includes a simple Express server as a sample backend. Please note that this server is not production-ready, and we advise using it solely for instructional purposes to demonstrate API calls.
|
|
24
|
+
|
|
25
|
+
### Thumbnails
|
|
26
|
+
|
|
27
|
+
This template illustrates how your API could return thumbnails and demonstrates their usage within the code. Thumbnails play a crucial role in optimizing image uploads and previews by providing quick visual feedback and reducing load times.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* This file creates a "database" out of a JSON file. It's only for
|
|
6
|
+
* demonstration purposes. A real app should use a real database.
|
|
7
|
+
*/
|
|
8
|
+
const DATABASE_FILE_PATH = path.join(__dirname, "db.json");
|
|
9
|
+
|
|
10
|
+
interface Database<T> {
|
|
11
|
+
read(): Promise<T>;
|
|
12
|
+
write(data: T): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class JSONFileDatabase<T> implements Database<T> {
|
|
16
|
+
constructor(private readonly seedData: T) {}
|
|
17
|
+
|
|
18
|
+
// Creates a database file if one doesn't already exist
|
|
19
|
+
private async init(): Promise<void> {
|
|
20
|
+
try {
|
|
21
|
+
// Do nothing, since the database is initialized
|
|
22
|
+
await fs.stat(DATABASE_FILE_PATH);
|
|
23
|
+
} catch {
|
|
24
|
+
const file = JSON.stringify(this.seedData);
|
|
25
|
+
await fs.writeFile(DATABASE_FILE_PATH, file);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Loads and parses the database file
|
|
30
|
+
async read(): Promise<T> {
|
|
31
|
+
await this.init();
|
|
32
|
+
const file = await fs.readFile(DATABASE_FILE_PATH, "utf8");
|
|
33
|
+
return JSON.parse(file);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Overwrites the database file with provided data
|
|
37
|
+
async write(data: T): Promise<void> {
|
|
38
|
+
await this.init();
|
|
39
|
+
const file = JSON.stringify(data);
|
|
40
|
+
await fs.writeFile(DATABASE_FILE_PATH, file);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import * as chalk from "chalk";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import "dotenv/config";
|
|
4
|
+
import * as express from "express";
|
|
5
|
+
import * as cookieParser from "cookie-parser";
|
|
6
|
+
import * as basicAuth from "express-basic-auth";
|
|
7
|
+
import { JSONFileDatabase } from "../database/database";
|
|
8
|
+
import { getTokenFromQueryString } from "../../utils/backend/jwt_middleware/jwt_middleware";
|
|
9
|
+
import { createJwtMiddleware } from "../../utils/backend/jwt_middleware";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* These are the hard-coded credentials for logging in to this template.
|
|
13
|
+
*/
|
|
14
|
+
const USERNAME = "username";
|
|
15
|
+
const PASSWORD = "password";
|
|
16
|
+
|
|
17
|
+
const COOKIE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
18
|
+
|
|
19
|
+
const CANVA_BASE_URL = "https://canva.com";
|
|
20
|
+
|
|
21
|
+
interface Data {
|
|
22
|
+
users: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* For more information on Authentication, refer to our documentation: {@link https://www.canva.dev/docs/apps/authenticating-users/#types-of-authentication}.
|
|
27
|
+
*/
|
|
28
|
+
export const createAuthRouter = () => {
|
|
29
|
+
const APP_ID = getAppId();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Set up a database with a "users" table. In this example code, the
|
|
33
|
+
* database is simply a JSON file.
|
|
34
|
+
*/
|
|
35
|
+
const db = new JSONFileDatabase<Data>({ users: [] });
|
|
36
|
+
|
|
37
|
+
const router = express.Router();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `cookieParser` middleware allows the routes to read and write cookies.
|
|
41
|
+
*
|
|
42
|
+
* By passing a value into the middleware, we enable the "signed cookies" feature of Express.js. The
|
|
43
|
+
* value should be static and cryptographically generated. If it's dynamic (as shown below), cookies
|
|
44
|
+
* won't persist between server restarts.
|
|
45
|
+
*
|
|
46
|
+
* TODO: Replace `crypto.randomUUID()` with a static value, loaded via an environment variable.
|
|
47
|
+
*/
|
|
48
|
+
router.use(cookieParser(crypto.randomUUID()));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* This endpoint is hit at the start of the authentication flow. It contains a state which must
|
|
52
|
+
* be passed back to canva so that Canva can verify the response. It must also set a nonce in the
|
|
53
|
+
* user's browser cookies and send the nonce back to Canva as a url parameter.
|
|
54
|
+
*
|
|
55
|
+
* If Canva can validate the state, it will then redirect back to the chosen redirect url.
|
|
56
|
+
*/
|
|
57
|
+
router.get("/configuration/start", async (req, res) => {
|
|
58
|
+
/**
|
|
59
|
+
* Generate a unique nonce for each request. A nonce is a random, single-use value
|
|
60
|
+
* that's impossible to guess or enumerate. We recommended using a Version 4 UUID that
|
|
61
|
+
* is cryptographically secure, such as one generated by the `randomUUID` method.
|
|
62
|
+
*/
|
|
63
|
+
const nonce = crypto.randomUUID();
|
|
64
|
+
// Set the expiry time for the nonce. We recommend 5 minutes.
|
|
65
|
+
const expiry = Date.now() + COOKIE_EXPIRY_MS;
|
|
66
|
+
|
|
67
|
+
// Create a JSON string that contains the nonce and an expiry time
|
|
68
|
+
const nonceWithExpiry = JSON.stringify([nonce, expiry]);
|
|
69
|
+
|
|
70
|
+
// Set a cookie that contains the nonce and the expiry time
|
|
71
|
+
res.cookie("nonce", nonceWithExpiry, {
|
|
72
|
+
secure: true,
|
|
73
|
+
httpOnly: true,
|
|
74
|
+
maxAge: COOKIE_EXPIRY_MS,
|
|
75
|
+
signed: true,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Create the query parameters that Canva requires
|
|
79
|
+
const params = new URLSearchParams({
|
|
80
|
+
nonce,
|
|
81
|
+
state: req?.query?.state?.toString() || "",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Redirect to Canva with required parameters
|
|
85
|
+
res.redirect(302, `${CANVA_BASE_URL}/apps/configure/link?${params}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* This endpoint renders a login page. Once the user logs in, they're
|
|
90
|
+
* redirected back to Canva, which completes the authentication flow.
|
|
91
|
+
*/
|
|
92
|
+
router.get(
|
|
93
|
+
"/redirect-url",
|
|
94
|
+
/**
|
|
95
|
+
* Use a JSON Web Token (JWT) to verify incoming requests. The JWT is
|
|
96
|
+
* extracted from the `canva_user_token` parameter.
|
|
97
|
+
*/
|
|
98
|
+
createJwtMiddleware(APP_ID, getTokenFromQueryString),
|
|
99
|
+
/**
|
|
100
|
+
* Warning: For demonstration purposes, we're using basic authentication and
|
|
101
|
+
* hard- coding a username and password. This is not a production-ready
|
|
102
|
+
* solution!
|
|
103
|
+
*/
|
|
104
|
+
basicAuth({
|
|
105
|
+
users: { [USERNAME]: PASSWORD },
|
|
106
|
+
challenge: true,
|
|
107
|
+
}),
|
|
108
|
+
async (req, res) => {
|
|
109
|
+
const failureResponse = () => {
|
|
110
|
+
const params = new URLSearchParams({
|
|
111
|
+
success: "false",
|
|
112
|
+
state: req.query.state?.toString() || "",
|
|
113
|
+
});
|
|
114
|
+
res.redirect(302, `${CANVA_BASE_URL}/apps/configured?${params}`);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Get the nonce and expiry time stored in the cookie.
|
|
118
|
+
const cookieNonceAndExpiry = req.signedCookies.nonce;
|
|
119
|
+
|
|
120
|
+
// Get the nonce from the query parameter.
|
|
121
|
+
const queryNonce = req.query.nonce?.toString();
|
|
122
|
+
|
|
123
|
+
// After reading the cookie, clear it. This forces abandoned auth flows to be restarted.
|
|
124
|
+
res.clearCookie("nonce");
|
|
125
|
+
|
|
126
|
+
let cookieNonce = "";
|
|
127
|
+
let expiry = 0;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
[cookieNonce, expiry] = JSON.parse(cookieNonceAndExpiry);
|
|
131
|
+
} catch {
|
|
132
|
+
// If the nonce can't be parsed, assume something has been compromised and exit.
|
|
133
|
+
return failureResponse();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// If the nonces are empty, exit the authentication flow.
|
|
137
|
+
if (
|
|
138
|
+
isEmpty(cookieNonceAndExpiry) ||
|
|
139
|
+
isEmpty(queryNonce) ||
|
|
140
|
+
isEmpty(cookieNonce)
|
|
141
|
+
) {
|
|
142
|
+
return failureResponse();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check that:
|
|
147
|
+
*
|
|
148
|
+
* - The nonce in the cookie and query parameter contain the same value
|
|
149
|
+
* - The nonce has not expired
|
|
150
|
+
*
|
|
151
|
+
* **Note:** We could rely on the cookie expiry, but that is vulnerable to tampering
|
|
152
|
+
* with the browser's time. This allows us to double-check based on server time.
|
|
153
|
+
*/
|
|
154
|
+
if (expiry < Date.now() || cookieNonce !== queryNonce) {
|
|
155
|
+
return failureResponse();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Get the userId from JWT middleware
|
|
159
|
+
const { userId } = req.canva;
|
|
160
|
+
|
|
161
|
+
// Load the database
|
|
162
|
+
const data = await db.read();
|
|
163
|
+
|
|
164
|
+
// Add the user to the database
|
|
165
|
+
if (!data.users.includes(userId)) {
|
|
166
|
+
data.users.push(userId);
|
|
167
|
+
await db.write(data);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Create query parameters for redirecting back to Canva
|
|
171
|
+
const params = new URLSearchParams({
|
|
172
|
+
success: "true",
|
|
173
|
+
state: req?.query?.state?.toString() || "",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Redirect the user back to Canva
|
|
177
|
+
res.redirect(302, `${CANVA_BASE_URL}/apps/configured?${params}`);
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* TODO: Add this middleware to all routes that will receive requests from
|
|
183
|
+
* your app.
|
|
184
|
+
*/
|
|
185
|
+
const jwtMiddleware = createJwtMiddleware(APP_ID);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* This endpoint is called when a user disconnects an app from their account.
|
|
189
|
+
* The app is expected to de-authenticate the user on its backend, so if the
|
|
190
|
+
* user reconnects the app, they'll need to re-authenticate.
|
|
191
|
+
*
|
|
192
|
+
* Note: The name of the endpoint is *not* configurable.
|
|
193
|
+
*
|
|
194
|
+
* Note: This endpoint is called by Canva's backend directly and must be
|
|
195
|
+
* exposed via a public URL. To test this endpoint, add a proxy URL, such as
|
|
196
|
+
* one generated by nGrok, to the 'Add authentication' section in the
|
|
197
|
+
* Developer Portal. Localhost addresses will not work to test this endpoint.
|
|
198
|
+
*/
|
|
199
|
+
router.post("/configuration/delete", jwtMiddleware, async (req, res) => {
|
|
200
|
+
// Get the userId from JWT middleware
|
|
201
|
+
const { userId } = req.canva;
|
|
202
|
+
|
|
203
|
+
// Load the database
|
|
204
|
+
const data = await db.read();
|
|
205
|
+
|
|
206
|
+
// Remove the user from the database
|
|
207
|
+
await db.write({
|
|
208
|
+
users: data.users.filter((user) => user !== userId),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Confirm that the user was removed
|
|
212
|
+
res.send({
|
|
213
|
+
type: "SUCCESS",
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* All routes that start with /api will be protected by JWT authentication
|
|
219
|
+
*/
|
|
220
|
+
router.use("/api", jwtMiddleware);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* This endpoint checks if a user is authenticated.
|
|
224
|
+
*/
|
|
225
|
+
router.post("/api/authentication/status", async (req, res) => {
|
|
226
|
+
// Load the database
|
|
227
|
+
const data = await db.read();
|
|
228
|
+
|
|
229
|
+
// Check if the user is authenticated
|
|
230
|
+
const isAuthenticated = data.users.includes(req.canva.userId);
|
|
231
|
+
|
|
232
|
+
// Return the authentication status
|
|
233
|
+
res.send({
|
|
234
|
+
isAuthenticated,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return router;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Checks if a given param is nullish or an empty string
|
|
243
|
+
*
|
|
244
|
+
* @param str The string to check
|
|
245
|
+
* @returns true if the string is nullish or empty, false otherwise
|
|
246
|
+
*/
|
|
247
|
+
function isEmpty(str?: string): boolean {
|
|
248
|
+
return str == null || str.length === 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Retrieves the CANVA_APP_ID from the environment variables.
|
|
253
|
+
* Throws an error if the CANVA_APP_ID environment variable is undefined or set to a default value.
|
|
254
|
+
*
|
|
255
|
+
* @returns {string} The Canva App ID
|
|
256
|
+
* @throws {Error} If CANVA_APP_ID environment variable is undefined or set to a default value
|
|
257
|
+
*/
|
|
258
|
+
function getAppId(): string {
|
|
259
|
+
// TODO: Set the CANVA_APP_ID environment variable in the project's .env file
|
|
260
|
+
const appId = process.env.CANVA_APP_ID;
|
|
261
|
+
|
|
262
|
+
if (!appId) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`The CANVA_APP_ID environment variable is undefined. Set the variable in the project's .env file.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (appId === "YOUR_APP_ID_HERE") {
|
|
269
|
+
// eslint-disable-next-line no-console
|
|
270
|
+
console.log(
|
|
271
|
+
chalk.bgRedBright(
|
|
272
|
+
"Default 'CANVA_APP_ID' environment variable detected.",
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
// eslint-disable-next-line no-console
|
|
276
|
+
console.log(
|
|
277
|
+
"Please update the 'CANVA_APP_ID' environment variable in your project's `.env` file " +
|
|
278
|
+
`with the App ID obtained from the Canva Developer Portal: ${chalk.greenBright(
|
|
279
|
+
"https://www.canva.com/developers/apps",
|
|
280
|
+
)}\n`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return appId;
|
|
285
|
+
}
|