Skip to content

Commit ab515d2

Browse files
authored
Merge pull request #30 from mitgdev/feat/discord
feat/discord-integration
2 parents 9614e71 + d375580 commit ab515d2

File tree

77 files changed

+2836
-122
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+2836
-122
lines changed

.vscode/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
"nodir",
141141
"nomount",
142142
"notcollected",
143+
"oauths",
143144
"offlinetraining",
144145
"oldnames",
145146
"oncreate",
@@ -167,6 +168,7 @@
167168
"quintenary",
168169
"raceid",
169170
"remainingdailytournamentplaytime",
171+
"Repliable",
170172
"requirepass",
171173
"restrictedstore",
172174
"retrohardcore",

apps/api/.env.example

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,15 @@ MAILER_SMTP_PASS=your_email_password
4747
4848
#MAILER_GOOGLE_CLIENT_ID=your_google_client_id
4949
#MAILER_GOOGLE_CLIENT_SECRET=your_google_client_secret
50-
#MAILER_GOOGLE_REFRESH_TOKEN=your_google_refresh_token
50+
#MAILER_GOOGLE_REFRESH_TOKEN=your_google_refresh_token
51+
52+
53+
54+
# Discord Configuration
55+
# If you enable Discord integration, make sure to fill all the fields below.
56+
DISCORD_ENABLED=false
57+
#DISCORD_TOKEN=your_discord_bot_token_here
58+
#DISCORD_CLIENT_ID=your_discord_client_id_here
59+
#DISCORD_CLIENT_SECRET=your_discord_client_secret_here
60+
#DISCORD_REDIRECT_URI=https://yoursite.com/v1/accounts/oauth/discord/callback
61+
#DISCORD_GUILD_ID=your_discord_guild_id_here

apps/api/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
"@orpc/zod": "catalog:orpc",
3232
"@prisma/adapter-mariadb": "^6.19.0",
3333
"@prisma/client": "^6.19.0",
34+
"axios": "^1.13.2",
35+
"axios-retry": "^4.5.0",
3436
"bullmq": "^5.65.1",
37+
"discord.js": "^14.25.1",
3538
"dotenv-flow": "^4.1.0",
3639
"fast-xml-parser": "^5.3.2",
3740
"hono": "^4.10.7",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- CreateTable
2+
CREATE TABLE `miforge_account_oauths` (
3+
`id` INTEGER NOT NULL AUTO_INCREMENT,
4+
`provider` ENUM('DISCORD') NOT NULL,
5+
`provider_account_id` VARCHAR(255) NOT NULL,
6+
`username` VARCHAR(255) NULL,
7+
`display_name` VARCHAR(255) NULL,
8+
`email` VARCHAR(255) NULL,
9+
`avatar_url` VARCHAR(512) NULL,
10+
`access_token` TEXT NULL,
11+
`refresh_token` TEXT NULL,
12+
`expires_at` DATETIME(3) NULL,
13+
`scope` VARCHAR(255) NULL,
14+
`raw_profile` JSON NULL,
15+
`account_id` INTEGER UNSIGNED NOT NULL,
16+
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
17+
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
18+
19+
INDEX `oauth_account_provider_idx`(`account_id`, `provider`),
20+
UNIQUE INDEX `miforge_account_oauths_provider_provider_account_id_key`(`provider`, `provider_account_id`),
21+
PRIMARY KEY (`id`)
22+
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
23+
24+
-- AddForeignKey
25+
ALTER TABLE `miforge_account_oauths` ADD CONSTRAINT `miforge_account_oauths_account_id_fkey` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE `miforge_account_confirmations` MODIFY `type` ENUM('EMAIL_VERIFICATION', 'PASSWORD_RESET', 'EMAIL_CHANGE', 'LOST_PASSWORD_RESET', 'DISCORD_OAUTH_LINK') NOT NULL;

apps/api/prisma/models/base.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ model accounts {
100100
registrations miforge_account_registration?
101101
audits miforge_account_audit[]
102102
confirmations miforge_account_confirmations[]
103+
oauths miforge_account_oauths[]
103104
104105
two_factor_enabled Boolean @default(false)
105106
two_factor_secret String? @db.VarChar(64)

apps/api/prisma/models/confirmations.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ enum MiforgeAccountConfirmationType {
33
PASSWORD_RESET
44
EMAIL_CHANGE
55
LOST_PASSWORD_RESET
6+
DISCORD_OAUTH_LINK
67
}
78

89
enum MiforgeAccountConfirmationChannel {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
enum OAuthProvider {
2+
DISCORD
3+
}
4+
5+
model miforge_account_oauths {
6+
id Int @id @default(autoincrement())
7+
8+
provider OAuthProvider
9+
10+
providerAccountId String @map("provider_account_id") @db.VarChar(255)
11+
12+
username String? @db.VarChar(255)
13+
displayName String? @map("display_name") @db.VarChar(255)
14+
email String? @db.VarChar(255)
15+
avatarUrl String? @map("avatar_url") @db.VarChar(512)
16+
17+
accessToken String? @map("access_token") @db.Text
18+
refreshToken String? @map("refresh_token") @db.Text
19+
expiresAt DateTime? @map("expires_at")
20+
scope String? @db.VarChar(255)
21+
22+
rawProfile Json? @map("raw_profile")
23+
24+
accountId Int @map("account_id") @db.UnsignedInt
25+
account accounts @relation(fields: [accountId], references: [id], onDelete: Cascade)
26+
27+
created_at DateTime @default(now())
28+
updated_at DateTime @default(now()) @updatedAt
29+
30+
/// Um mesmo (provider, providerAccountId) não pode estar em dois usuários
31+
@@unique([provider, providerAccountId], name: "oauth_provider_account_unique")
32+
/// Index pra listar conexões de uma conta
33+
@@index([accountId, provider], name: "oauth_account_provider_idx")
34+
}

apps/api/prisma/seed/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ const miforgeConfig = MiforgeConfigSchema.decode({
3636
mailer: {
3737
enabled: Boolean(env.MAILER_PROVIDER)
3838
},
39+
discord: {
40+
enabled: Boolean(env.DISCORD_ENABLED)
41+
},
3942
account: {
4043
emailConfirmationRequired: Boolean(env.MAILER_PROVIDER),
4144
emailChangeConfirmationRequired: Boolean(env.MAILER_PROVIDER),
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { ORPCError } from "@orpc/client";
2+
import { inject, injectable } from "tsyringe";
3+
import { Catch } from "@/application/decorators/Catch";
4+
import type { DiscordApiClient } from "@/domain/clients";
5+
import type { ExecutionContext } from "@/domain/context";
6+
import type {
7+
AccountConfirmationsRepository,
8+
AccountOauthRepository,
9+
AccountRepository,
10+
} from "@/domain/repositories";
11+
import { TOKENS } from "@/infra/di/tokens";
12+
import { env } from "@/infra/env";
13+
import type { AccountConfirmationsService } from "../accountConfirmations";
14+
15+
@injectable()
16+
export class AccountOauthService {
17+
constructor(
18+
@inject(TOKENS.AccountConfirmationsService)
19+
private readonly accountConfirmationsService: AccountConfirmationsService,
20+
@inject(TOKENS.AccountConfirmationsRepository)
21+
private readonly accountConfirmationsRepository: AccountConfirmationsRepository,
22+
@inject(TOKENS.ExecutionContext)
23+
private readonly executionContext: ExecutionContext,
24+
@inject(TOKENS.AccountRepository)
25+
private readonly accountRepository: AccountRepository,
26+
@inject(TOKENS.AccountOauthRepository)
27+
private readonly accountOauthRepository: AccountOauthRepository,
28+
@inject(TOKENS.DiscordApiClient)
29+
private readonly discordApiClient: DiscordApiClient,
30+
) {}
31+
32+
@Catch()
33+
async requestDiscordLink() {
34+
if (!env.DISCORD_ENABLED) {
35+
throw new ORPCError("UNAVAILABLE", {
36+
message: "[Discord] integration is disabled",
37+
});
38+
}
39+
40+
const session = this.executionContext.session();
41+
42+
const account = await this.accountRepository.findByEmail(session.email);
43+
44+
if (!account) {
45+
throw new ORPCError("NOT_FOUND", {
46+
message: "Account not found",
47+
});
48+
}
49+
50+
const alreadyLink =
51+
await this.accountOauthRepository.findByProviderAccountId(
52+
"DISCORD",
53+
account.id,
54+
);
55+
56+
if (alreadyLink) {
57+
throw new ORPCError("CONFLICT", {
58+
message: "This account is already linked to a Discord account",
59+
});
60+
}
61+
62+
const alreadyHasConfirmation =
63+
await this.accountConfirmationsRepository.findByAccountAndType(
64+
account.id,
65+
"DISCORD_OAUTH_LINK",
66+
);
67+
68+
if (alreadyHasConfirmation) {
69+
throw new ORPCError("CONFLICT", {
70+
message:
71+
"There is already a pending Discord link confirmation for this account",
72+
data: {
73+
expiresAt: alreadyHasConfirmation.expires_at,
74+
},
75+
});
76+
}
77+
78+
const { token, tokenHash, expiresAt } =
79+
await this.accountConfirmationsService.generateTokenAndHash(5);
80+
81+
await this.accountConfirmationsRepository.create(account.id, {
82+
channel: "CODE",
83+
type: "DISCORD_OAUTH_LINK",
84+
tokenHash,
85+
expiresAt,
86+
});
87+
88+
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_REDIRECT_URI) {
89+
throw new ORPCError("UNAVAILABLE", {
90+
message: "Discord OAuth configuration is missing",
91+
});
92+
}
93+
94+
const params = new URLSearchParams({
95+
client_id: env.DISCORD_CLIENT_ID,
96+
redirect_uri: env.DISCORD_REDIRECT_URI,
97+
response_type: "code",
98+
scope: ["identify", "email"].join(" "),
99+
state: token,
100+
prompt: "consent",
101+
});
102+
103+
const discordBaseUrl = this.discordApiClient.getRouteOauthRoute();
104+
105+
return {
106+
url: `${discordBaseUrl}?${params.toString()}`,
107+
};
108+
}
109+
110+
@Catch()
111+
async confirmDiscordLink(code: string, state: string) {
112+
if (!env.DISCORD_ENABLED) {
113+
throw new ORPCError("UNAVAILABLE", {
114+
message: "[Discord] integration is disabled",
115+
});
116+
}
117+
118+
const session = this.executionContext.session();
119+
120+
const account = await this.accountRepository.findByEmail(session.email);
121+
122+
if (!account) {
123+
throw new ORPCError("NOT_FOUND", {
124+
message: "Account not found",
125+
});
126+
}
127+
128+
const confirmation = await this.accountConfirmationsService.isValid(state);
129+
130+
const token = await this.discordApiClient.exchangeCodeForToken(code);
131+
const discordUser = await this.discordApiClient.getUserInfo(
132+
token.access_token,
133+
);
134+
135+
await this.accountOauthRepository.upsert(
136+
{
137+
accessToken: token.access_token,
138+
provider: "DISCORD",
139+
avatarUrl: null,
140+
displayName: discordUser.username,
141+
email: discordUser.email ?? null,
142+
refreshToken: token.refresh_token ?? null,
143+
expiresAt: new Date(Date.now() + token.expires_in * 1000),
144+
username: discordUser.username,
145+
},
146+
{
147+
accountId: account.id,
148+
providerAccountId: discordUser.id,
149+
},
150+
);
151+
152+
await this.accountConfirmationsService.verifyConfirmation(
153+
confirmation,
154+
state,
155+
);
156+
157+
return {
158+
url: `${env.FRONTEND_URL}/account/details`,
159+
};
160+
}
161+
162+
@Catch()
163+
async unlinkDiscord() {
164+
const session = this.executionContext.session();
165+
166+
const account = await this.accountRepository.findByEmail(session.email);
167+
168+
if (!account) {
169+
throw new ORPCError("NOT_FOUND", {
170+
message: "Account not found",
171+
});
172+
}
173+
174+
const oauthAccount =
175+
await this.accountOauthRepository.findByProviderAccountId(
176+
"DISCORD",
177+
account.id,
178+
);
179+
180+
if (!oauthAccount) {
181+
throw new ORPCError("NOT_FOUND", {
182+
message: "No linked Discord account found",
183+
});
184+
}
185+
186+
await this.accountOauthRepository.deleteById(oauthAccount.id);
187+
}
188+
}

0 commit comments

Comments
 (0)