Skip to content

Commit c54b062

Browse files
committed
feat: migrate auth method from YouTube to Discord
1 parent ab7276b commit c54b062

24 files changed

+900
-915
lines changed

.dockerignore

+1
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ node_modules
1010
/src/**/*.test.ts
1111
/tmp
1212
/data
13+
*.tsbuildinfo

CONTRIBUTING.md

+5-6
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
## Development Guide
44

55
```bash
6-
git clone https://github.com/holodata/shipid.git && cd shipid
6+
git clone https://github.com/holodata/ShipID.git && cd ShipID
77
npm install
88
cp .env.placeholder .env
99
vim .env
1010
npm run devcontainer
1111

1212
```
1313

14-
## Release Guide (Maintainers only)
14+
## Deploy Guide (Maintainers only)
1515

1616
```bash
1717
git pull
@@ -29,7 +29,6 @@ db.createUser({
2929
## References
3030

3131
- [discord.js](https://discord.js.org/#/docs/main/stable/general/welcome)
32-
- [discordjs-bot-guide/roles.md at master · AnIdiotsGuide/discordjs-bot-guide](https://github.com/AnIdiotsGuide/discordjs-bot-guide/blob/master/understanding/roles.md)
33-
- [googleapis/google-api-nodejs-client](https://github.com/googleapis/google-api-nodejs-client)
34-
- [Server Onboarding - Gentei](https://docs.member-gentei.tindabox.net/Discord/server-onboarding)
35-
32+
- [Discord.js Guide](https://discordjs.guide/#before-you-begin)
33+
- [discordjs-bot-guide/roles.md](https://github.com/AnIdiotsGuide/discordjs-bot-guide/blob/master/understanding/roles.md)
34+
- [Gentei](https://docs.member-gentei.tindabox.net/Discord/server-onboarding)

Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ RUN yarn install --frozen-lockfile
77
COPY . .
88
RUN yarn build
99

10-
CMD ["node", "./lib/server.js"]
10+
CMD ["node", "./lib/server.js"]

README.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
# ShipID
44

5-
A Discord bot provides automated YouTube membership verification and role assignment services on your server.
5+
> A fully-automated YouTube membership verification & role-assignment bot for Discord
66
7-
> Under development stage. Join our Discord server for the further info.
7+
> Under development stage. Join our Discord server for participating in dev process.
88
9-
**ShipID** is inspired by [Gentei](https://github.com/member-gentei/member-gentei) project. As a way to verify a user's membership, we are relying on the vast amount of live chat collected by our [Honeybee](https://github.com/holodata/honeybee) cluster. Therefore, minimal permissions are sufficient to check a user's YouTube account (we only ask for `youtube.read` scope).
9+
**ShipID** is inspired by [Gentei](https://github.com/member-gentei/member-gentei) project.
10+
As a way to verify a user's membership, we are relying on the vast amount of live chat collected by our [Honeybee](https://github.com/holodata/honeybee), and video comments as a backup strategy.

package.json

+14-16
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
{
22
"name": "shipid",
3-
"description": "YouTube Membership Verification Bot for Discord",
3+
"description": "Fully-automated YouTube Membership Verification Bot",
44
"version": "0.0.0",
55
"author": "Yasuaki Uechi <[email protected]> (https://uechi.io/)",
66
"scripts": {
77
"build": "tsc",
8-
"clean": "shx rm -rf lib",
8+
"clean": "shx rm -rf lib && docker-compose down --rmi local",
9+
"deploy-commands": "ts-node src/modules/discord/deploy-commands.ts",
910
"dev": "run-p dev:*",
1011
"dev:server": "nodemon -w lib .",
1112
"dev:tsc": "tsc -w",
12-
"devcontainer": "docker-compose -f docker-compose.development.yml up",
13+
"devcontainer": "docker-compose -f docker-compose.development.yml up | grep -v mongo_1",
14+
"predevcontainer": "npm run deploy-commands",
1315
"prepare": "husky install",
1416
"start": "node .",
1517
"test": "jest"
@@ -23,31 +25,30 @@
2325
"@discordjs/rest": "^0.1.0-canary.0",
2426
"@typegoose/typegoose": "^8.2.0",
2527
"debug": "^4.3.2",
26-
"discord-api-types": "^0.23.1",
28+
"discord-api-types": "^0.22.0",
2729
"discord.js": "^13.1.0",
2830
"express": "^4.17.1",
29-
"googleapis": "^85.0.0",
3031
"jsonwebtoken": "^8.5.1",
3132
"luxon": "^2.0.2",
32-
"moment": "^2.29.1",
33-
"mongoose": "5.10.18",
33+
"mongoose": "~5.13.8",
34+
"node-fetch": "2",
3435
"node-schedule": "^2.0.0"
3536
},
3637
"devDependencies": {
3738
"@types/debug": "^4.1.7",
3839
"@types/express": "^4.17.13",
3940
"@types/jest": "^27.0.1",
4041
"@types/jsonwebtoken": "^8.5.5",
41-
"@types/luxon": "^2.0.3",
42-
"@types/mongoose": "~5.10.2",
43-
"@types/node": "^16.9.1",
42+
"@types/luxon": "^2.0.2",
43+
"@types/node": "^16.7.8",
44+
"@types/node-fetch": "2",
4445
"@types/node-schedule": "^1.3.2",
4546
"husky": "^7.0.2",
46-
"jest": "^27.1.1",
47-
"masterchat": "^0.9.0",
47+
"jest": "^27.1.0",
48+
"masterchat": "^0.8.0",
4849
"nodemon": "^2.0.12",
4950
"npm-run-all": "^4.1.5",
50-
"prettier": "^2.4.0",
51+
"prettier": "^2.3.2",
5152
"pretty-quick": "^3.1.1",
5253
"shx": "^0.3.3",
5354
"ts-jest": "^27.0.5",
@@ -63,9 +64,6 @@
6364
"url": "https://github.com/holodata/shipid/issues"
6465
},
6566
"license": "Apache-2.0",
66-
"keywords": [
67-
"shipid"
68-
],
6967
"engines": {
7068
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
7169
},

src/constants.ts

+7-9
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,23 @@ import assert from "assert";
22

33
export const isDev = process.env.NODE_ENV !== "production";
44

5-
export const PREFIX = process.env.BOT_PREFIX || "!sid";
5+
export const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID!;
6+
assert(DISCORD_CLIENT_ID, "DISCORD_TOKEN is missing");
7+
8+
export const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET!;
9+
assert(DISCORD_CLIENT_SECRET, "DISCORD_TOKEN is missing");
610

711
export const DISCORD_TOKEN = process.env.DISCORD_TOKEN!;
812
assert(DISCORD_TOKEN, "DISCORD_TOKEN is missing");
913

1014
export const MONGODB_URL = process.env.MONGODB_URL!;
11-
// assert(MONGODB_URL, "MONGODB_URL is missing");
12-
13-
export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID!;
14-
assert(GOOGLE_CLIENT_ID, "GOOGLE_CLIENT_ID is missing");
15-
16-
export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET!;
17-
assert(GOOGLE_CLIENT_SECRET, "GOOGLE_CLIENT_SECRET is missing");
15+
if (!isDev) assert(MONGODB_URL, "MONGODB_URL is missing");
1816

1917
export const JWT_SECRET = process.env.JWT_SECRET!;
2018
assert(JWT_SECRET);
2119

2220
export const HB_MONGO_URI = process.env.HB_MONGO_URI!;
23-
assert(HB_MONGO_URI);
21+
if (!isDev) assert(HB_MONGO_URI);
2422

2523
export const PORT = Number(process.env.PORT || 3000);
2624

src/db.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import UserModel, { User } from "./models/user";
21
import GuildModel from "./models/guild";
2+
import UserModel, { User } from "./models/user";
33
import VerificationModel, { Status } from "./models/verification";
4-
import { Membership } from "masterchat";
54

65
export async function getUserByDiscordId(discordId: string) {
76
return await UserModel.findOne({ discordId });

src/models/verification.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Membership } from "masterchat";
21
import mongoose, { Schema } from "mongoose";
32
import { User } from "./user";
43

src/auth.ts src/modules/auth.ts

File renamed without changes.

src/modules/discord/auth.ts

+173
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { APIConnection } from "discord-api-types";
2+
import { Request, Response, Router } from "express";
3+
import jwt from "jsonwebtoken";
4+
import fetch from "node-fetch";
5+
import { JWT_SECRET } from "../../constants";
6+
import { createOrUpdateUser } from "../../db";
7+
import { log } from "../../util";
8+
import { JwtToken } from "../auth";
9+
10+
const DISCORD_AUTHORIZE_URL = "https://discord.com/api/oauth2/authorize";
11+
const DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token";
12+
13+
function renderHTML(res: Response, content: string) {
14+
res.contentType("html");
15+
res.end(`<!DOCTYPE html>
16+
<html lang="en">
17+
<head>
18+
<meta charset="UTF-8">
19+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
20+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
21+
<title>ShipID</title>
22+
<style>
23+
html, body {
24+
height: 100%;
25+
font-family: sans-serif;
26+
flex-direction: column;
27+
display: flex;
28+
justify-content: center;
29+
align-items: center;
30+
text-align: center;
31+
}
32+
33+
h1 {
34+
font-size: 50pt;
35+
}
36+
37+
</style>
38+
</head>
39+
<body>
40+
<h1>🚢 ShipID</h1>
41+
<p>${content}</p>
42+
</body>
43+
</html>`);
44+
}
45+
46+
async function getConnections(token: string): Promise<APIConnection[]> {
47+
const res = await fetch("https://discord.com/api/users/@me/connections", {
48+
headers: { Authorization: `Bearer ${token}` },
49+
});
50+
if (res.status !== 200) throw new Error(res.statusText);
51+
return await res.json();
52+
}
53+
54+
export function createDiscordOAuthHandler({
55+
clientId,
56+
clientSecret,
57+
redirectUri,
58+
}: {
59+
clientId: string;
60+
clientSecret: string;
61+
redirectUri: string;
62+
}) {
63+
function authorize(req: Request, res: Response) {
64+
const state = req.query.state;
65+
if (typeof state !== "string") {
66+
return renderHTML(res.status(403), "no valid state found");
67+
}
68+
const params = {
69+
response_type: "code",
70+
client_id: clientId,
71+
scope: "connections",
72+
state,
73+
redirect_uri: redirectUri,
74+
prompt: "consent",
75+
};
76+
const authorizeUrl =
77+
DISCORD_AUTHORIZE_URL + "?" + new URLSearchParams(params);
78+
res.redirect(authorizeUrl);
79+
}
80+
81+
async function getToken(code: string) {
82+
const body = {
83+
client_id: clientId,
84+
client_secret: clientSecret,
85+
grant_type: "authorization_code",
86+
code: code,
87+
redirect_uri: redirectUri,
88+
};
89+
const res = await fetch(DISCORD_TOKEN_URL, {
90+
method: "POST",
91+
body: new URLSearchParams(body),
92+
});
93+
return await res.json();
94+
}
95+
96+
async function callback(req: Request, res: Response) {
97+
const code = req.query.code;
98+
if (typeof code !== "string") {
99+
return renderHTML(res.status(403), "Invalid request");
100+
}
101+
102+
// validate state
103+
const state = req.query.state;
104+
if (typeof state !== "string") {
105+
return renderHTML(res.status(403), "Invalid state");
106+
}
107+
108+
let id_token: JwtToken;
109+
try {
110+
id_token = jwt.verify(state, JWT_SECRET) as JwtToken;
111+
} catch (err) {
112+
return renderHTML(res.status(403), `Invalid state`);
113+
}
114+
115+
if (Date.now() - id_token.iat > 1000 * 60 * 5) {
116+
return renderHTML(
117+
res.status(403),
118+
`Your request has been expired. Try /verify again.`
119+
);
120+
}
121+
122+
// handle the code
123+
const discordId = id_token.discordId;
124+
console.log(discordId);
125+
126+
try {
127+
const token = await getToken(code);
128+
129+
const connections = await getConnections(token.access_token);
130+
const youtubeConnection = connections.find(
131+
(conn) => conn.type === "youtube"
132+
);
133+
if (!youtubeConnection) {
134+
// add YouTube to Discord account first!
135+
return renderHTML(
136+
res.status(403),
137+
`Connect your YouTube account with Discord account first`
138+
);
139+
}
140+
141+
const channelName = youtubeConnection.name;
142+
const youtubeChannelId = youtubeConnection.id;
143+
144+
// create user
145+
const user = await createOrUpdateUser(discordId, {
146+
youtubeChannelId,
147+
});
148+
log(channelName, user);
149+
150+
renderHTML(
151+
res,
152+
`Your YouTube account has successfully been confirmed. Return to Discord app and try \`/verify\` command again on the server where you want member-specific roles.`
153+
);
154+
} catch (err: any) {
155+
if (err.code === "400") {
156+
// invalid grant
157+
return res.redirect("/discord/authorize");
158+
}
159+
console.log(err);
160+
renderHTML(
161+
res.status(500),
162+
"Unexpected error has occurred. Ask admin (uetchy#1717)"
163+
);
164+
}
165+
}
166+
167+
const router = Router();
168+
169+
router.get("/discord/authorize", authorize);
170+
router.get("/discord/callback", callback);
171+
172+
return router;
173+
}

0 commit comments

Comments
 (0)