Skip to content

Commit 54e68da

Browse files
feat: github verification
1 parent 689315a commit 54e68da

File tree

5 files changed

+212
-35
lines changed

5 files changed

+212
-35
lines changed

prisma/schema.prisma

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ model User {
162162
name String?
163163
username String
164164
discordId String
165+
githubId String?
165166
guilds String[] @default([])
166167
password String
167168
token String?
@@ -234,3 +235,25 @@ model VerificationEntry {
234235
@@unique([userId, guildId])
235236
@@map("verification_entries")
236237
}
238+
239+
model VerificationRecord {
240+
id Int @id @default(autoincrement())
241+
guildId String
242+
userId String
243+
discordId String?
244+
githubId String?
245+
googleId String?
246+
email String?
247+
method VerificationMethod
248+
createdAt DateTime @default(now())
249+
updatedAt DateTime @default(now()) @updatedAt
250+
251+
@@map("verification_records")
252+
}
253+
254+
enum VerificationMethod {
255+
DISCORD
256+
GITHUB
257+
GOOGLE
258+
EMAIL
259+
}

src/main/typescript/api/controllers/VerificationController.ts

Lines changed: 133 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,14 @@ import Response from "@framework/api/http/Response";
66
import { Inject } from "@framework/container/Inject";
77
import type VerificationService from "@main/automod/VerificationService";
88
import { env } from "@main/env/env";
9+
import { VerificationMethod } from "@prisma/client";
910
import undici from "undici";
1011
import { z } from "zod";
1112

1213
class VerificationController extends Controller {
1314
@Inject("verificationService")
1415
private readonly verificationService!: VerificationService;
1516

16-
// FIXME: This is a dummy implementation
17-
@Action("GET", "/verify")
18-
public async verify(request: Request) {
19-
if (!request.query.code) {
20-
return new Response({ status: 400, body: { error: "A code to verify is required." } });
21-
}
22-
23-
const result = await this.application
24-
.service("verificationService")
25-
.verifyByCode(
26-
typeof request.query.code === "string"
27-
? request.query.code
28-
: String(request.query.code)
29-
);
30-
31-
if (!result) {
32-
return new Response({ status: 403, body: { error: "Invalid code." } });
33-
}
34-
35-
return new Response({
36-
status: 302,
37-
headers: {
38-
Location: `https://discord.com/channels/${encodeURIComponent(result.guildId)}`
39-
}
40-
});
41-
}
42-
4317
@Action("POST", "/verification/guild")
4418
@Validate(
4519
z.object({
@@ -142,7 +116,10 @@ class VerificationController extends Controller {
142116

143117
const result = await this.application
144118
.service("verificationService")
145-
.verifyWithEntry(entry);
119+
.verifyWithEntry(entry, {
120+
discordId: (userData as Record<string, string>).id,
121+
method: VerificationMethod.DISCORD
122+
});
146123

147124
if (!result) {
148125
return new Response({
@@ -151,6 +128,13 @@ class VerificationController extends Controller {
151128
});
152129
}
153130

131+
if (result.error === "record_exists") {
132+
return new Response({
133+
status: 403,
134+
body: { error: "You cannot use this account to verify." }
135+
});
136+
}
137+
154138
return new Response({
155139
status: 200,
156140
body: {
@@ -166,6 +150,127 @@ class VerificationController extends Controller {
166150
body: { error: "We're unable to verify your Discord Account." }
167151
});
168152
}
153+
154+
@Action("POST", "/challenge/github")
155+
@Validate(
156+
z.object({
157+
code: z.string(),
158+
token: z.string()
159+
})
160+
)
161+
public async verifyByGitHub(request: Request) {
162+
const { code, token } = request.parsedBody ?? {};
163+
164+
if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) {
165+
return new Response({
166+
status: 403,
167+
body: { error: "GitHub OAuth is not supported." }
168+
});
169+
}
170+
171+
try {
172+
const body = new URLSearchParams({
173+
client_id: env.GITHUB_CLIENT_ID,
174+
client_secret: env.GITHUB_CLIENT_SECRET,
175+
code,
176+
redirect_uri: `${env.FRONTEND_URL}/challenge/github`
177+
}).toString();
178+
179+
const tokenResponse = await undici.request(
180+
"https://github.com/login/oauth/access_token",
181+
{
182+
method: "POST",
183+
body,
184+
headers: {
185+
"Content-Type": "application/x-www-form-urlencoded",
186+
Accept: "application/json"
187+
}
188+
}
189+
);
190+
191+
if (tokenResponse.statusCode > 299 || tokenResponse.statusCode < 200) {
192+
throw new Error(`Failed to get token: ${tokenResponse.statusCode}`);
193+
}
194+
195+
const oauthData = await tokenResponse.body.json();
196+
197+
if (typeof oauthData !== "object" || !oauthData) {
198+
throw new Error("Invalid token response");
199+
}
200+
201+
const { access_token, token_type, scope } = oauthData as Record<string, string>;
202+
203+
console.log(oauthData);
204+
205+
if (!scope.includes("read:user")) {
206+
return new Response({
207+
status: 403,
208+
body: { error: "You must authorize the read:user scope." }
209+
});
210+
}
211+
212+
const userResponse = await undici.request("https://api.github.com/user", {
213+
method: "GET",
214+
headers: {
215+
Authorization: `${token_type} ${access_token}`
216+
}
217+
});
218+
219+
if (userResponse.statusCode > 299 || userResponse.statusCode < 200) {
220+
throw new Error(`Failed to get user info: ${userResponse.statusCode}`);
221+
}
222+
223+
const userData = await userResponse.body.json();
224+
225+
if (typeof userData !== "object" || !userData) {
226+
throw new Error("Invalid user response");
227+
}
228+
229+
const entry = await this.verificationService.getVerificationEntry(token);
230+
231+
if (!entry || !(userData as Record<string, number>).id || entry.code !== token) {
232+
return new Response({
233+
status: 403,
234+
body: { error: "We're unable to verify you, please try again." }
235+
});
236+
}
237+
238+
const result = await this.application
239+
.service("verificationService")
240+
.verifyWithEntry(entry, {
241+
githubId: (userData as Record<string, number>).id?.toString(),
242+
method: VerificationMethod.GITHUB
243+
});
244+
245+
if (!result) {
246+
return new Response({
247+
status: 403,
248+
body: { error: "We're unable to verify you, please try again." }
249+
});
250+
}
251+
252+
if (result.error === "record_exists") {
253+
return new Response({
254+
status: 403,
255+
body: { error: "You cannot use this account to verify." }
256+
});
257+
}
258+
259+
return new Response({
260+
status: 200,
261+
body: {
262+
message: "You have been verified successfully."
263+
}
264+
});
265+
} catch (error) {
266+
this.application.logger.error(error);
267+
}
268+
269+
return new Response({
270+
status: 403,
271+
body: { error: "We're unable to verify your GitHub Account." }
272+
});
273+
}
169274
}
170275

171276
export default VerificationController;

src/main/typescript/automod/VerificationService.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { GatewayEventListener } from "@framework/events/GatewayEventListener";
33
import { Name } from "@framework/services/Name";
44
import { Service } from "@framework/services/Service";
55
import { Events } from "@framework/types/ClientEvents";
6-
import { fetchMember } from "@framework/utils/entities";
6+
import { fetchMember, fetchUser } from "@framework/utils/entities";
77
import { Colors } from "@main/constants/Colors";
88
import { env } from "@main/env/env";
99
import type ConfigurationManager from "@main/services/ConfigurationManager";
10-
import { VerificationEntry } from "@prisma/client";
10+
import type ModerationActionService from "@main/services/ModerationActionService";
11+
import { VerificationEntry, VerificationMethod } from "@prisma/client";
1112
import {
1213
ActionRowBuilder,
1314
ButtonBuilder,
@@ -25,6 +26,9 @@ class VerificationService extends Service {
2526
@Inject("configManager")
2627
private readonly configManager!: ConfigurationManager;
2728

29+
@Inject("moderationActionService")
30+
private readonly moderationActionService!: ModerationActionService;
31+
2832
private configFor(guildId: Snowflake) {
2933
return this.configManager.config[guildId]?.member_verification;
3034
}
@@ -153,23 +157,36 @@ class VerificationService extends Service {
153157
}
154158
});
155159

160+
const actions = this.configFor(entry.guildId)?.expired_actions;
161+
const guild = this.application.client.guilds.cache.get(entry.guildId);
162+
163+
if (actions?.length && guild) {
164+
const memberOrUser =
165+
(await fetchMember(guild, entry.userId)) ??
166+
(await fetchUser(this.application.client, entry.userId));
167+
168+
if (memberOrUser) {
169+
await this.moderationActionService.takeActions(guild, memberOrUser, actions);
170+
}
171+
}
172+
156173
return null;
157174
}
158175

159176
return entry;
160177
}
161178

162-
public async verifyByCode(code: string) {
179+
public async verifyByCode(code: string, payload: VerificationPayload) {
163180
const entry = await this.getVerificationEntry(code);
164181

165182
if (!entry) {
166183
return null;
167184
}
168185

169-
return this.verifyWithEntry(entry);
186+
return this.verifyWithEntry(entry, payload);
170187
}
171188

172-
public async verifyWithEntry(entry: VerificationEntry) {
189+
public async verifyWithEntry(entry: VerificationEntry, payload: VerificationPayload) {
173190
const config = this.configFor(entry.guildId);
174191

175192
if (!config?.enabled) {
@@ -182,6 +199,20 @@ class VerificationService extends Service {
182199
return null;
183200
}
184201

202+
const existingRecord = await this.application.prisma.verificationRecord.findFirst({
203+
where: {
204+
guildId: guild.id,
205+
userId: entry.userId,
206+
...payload
207+
}
208+
});
209+
210+
if (existingRecord) {
211+
return {
212+
error: "record_exists"
213+
};
214+
}
215+
185216
const member = await fetchMember(guild, entry.userId);
186217

187218
if (!member) {
@@ -202,6 +233,14 @@ class VerificationService extends Service {
202233
}
203234
});
204235

236+
await this.application.prisma.verificationRecord.create({
237+
data: {
238+
guildId: guild.id,
239+
userId: member.id,
240+
...payload
241+
}
242+
});
243+
205244
await member
206245
.send({
207246
embeds: [
@@ -230,4 +269,12 @@ class VerificationService extends Service {
230269
}
231270
}
232271

272+
export type VerificationPayload = {
273+
githubId?: string;
274+
googleId?: string;
275+
discordId?: string;
276+
email?: string;
277+
method: VerificationMethod;
278+
};
279+
233280
export default VerificationService;

src/main/typescript/schemas/EnvironmentVariableSchema.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ export const EnvironmentVariableSchema = z.object({
3636
.nullable()
3737
.transform(value => (value === "null" ? null : value)),
3838
API_NINJAS_JOKE_API_KEY: z.string().optional(),
39-
PIXABAY_TOKEN: z.string().optional()
39+
PIXABAY_TOKEN: z.string().optional(),
40+
GITHUB_CLIENT_ID: z.string().optional(),
41+
GITHUB_CLIENT_SECRET: z.string().optional()
4042
});
4143

4244
export type EnvironmentVariableRecord = z.infer<typeof EnvironmentVariableSchema>;

src/main/typescript/schemas/GuildConfigSchema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export const GuildConfigSchema = z.object({
211211
}),
212212
unverified_roles: z.array(zSnowflake).default([]),
213213
verified_roles: z.array(zSnowflake).default([]),
214-
failed_actions: z.array(ModerationActionSchema).default([]),
214+
expired_actions: z.array(ModerationActionSchema).default([]),
215215
verification_message: z.string().optional(),
216216
success_message: z.string().optional(),
217217
max_duration: z

0 commit comments

Comments
 (0)