diff --git a/.env.example b/.env.example
index ee33098..2765b65 100644
--- a/.env.example
+++ b/.env.example
@@ -28,6 +28,11 @@ FLY_ACCESS_TOKEN=
DATABASE_URL=postgresql://postgres:password@127.0.0.1:5432/culero
JWT_SECRET=secret
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+GITHUB_CALLBACK_URL=http://localhost:4200/api/auth/github/callback
+
+
SMTP_HOST=
SMTP_PORT=
SMTP_EMAIL_ADDRESS=
@@ -44,4 +49,6 @@ PROFILES_DIRECTORY=
REDIS_URL=redis://localhost:6379
LINKEDIN_PROFILE_FETCHER_API_KEY=
-LINKEDIN_PROFILE_FETCHER_HOST=
\ No newline at end of file
+LINKEDIN_PROFILE_FETCHER_HOST=\
+
+SESSION_SECRET=
\ No newline at end of file
diff --git a/package.json b/package.json
index eaa0dbb..2416c4c 100644
--- a/package.json
+++ b/package.json
@@ -51,11 +51,13 @@
"crypto-js": "^4.2.0",
"expo-server-sdk": "^3.10.0",
"express": "^4.19.2",
+ "express-session": "^1.18.0",
"install": "^0.13.0",
"ioredis": "^5.4.1",
"jest-mock-extended": "^3.0.5",
"nodemailer": "^6.9.12",
"passport-facebook": "^3.0.0",
+ "passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-linkedin-oauth2": "^2.0.0",
"prisma": "^5.10.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a7785f5..f36fc51 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -74,6 +74,9 @@ importers:
express:
specifier: ^4.19.2
version: 4.19.2
+ express-session:
+ specifier: ^1.18.0
+ version: 1.18.0
install:
specifier: ^0.13.0
version: 0.13.0
@@ -89,6 +92,9 @@ importers:
passport-facebook:
specifier: ^3.0.0
version: 3.0.0
+ passport-github2:
+ specifier: ^0.1.12
+ version: 0.1.12
passport-google-oauth20:
specifier: ^2.0.0
version: 2.0.0
@@ -1772,6 +1778,9 @@ packages:
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
+ cookie-signature@1.0.7:
+ resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
+
cookie@0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
@@ -2094,6 +2103,10 @@ packages:
expo-server-sdk@3.10.0:
resolution: {integrity: sha512-isymUVz18Syp9G+TPs2MVZ6WdMoyLw8hDLhpywOd8JqM6iGTka6Dr8Dzq7mjGQ8C8486rxLawZx/W+ps+vkjLQ==}
+ express-session@1.18.0:
+ resolution: {integrity: sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==}
+ engines: {node: '>= 0.8.0'}
+
express@4.18.2:
resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
engines: {node: '>= 0.10.0'}
@@ -3031,6 +3044,10 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
+ on-headers@1.0.2:
+ resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==}
+ engines: {node: '>= 0.8'}
+
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -3092,6 +3109,10 @@ packages:
resolution: {integrity: sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==}
engines: {node: '>= 0.4.0'}
+ passport-github2@0.1.12:
+ resolution: {integrity: sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==}
+ engines: {node: '>= 0.8.0'}
+
passport-google-oauth20@2.0.0:
resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==}
engines: {node: '>= 0.4.0'}
@@ -3268,6 +3289,10 @@ packages:
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
+ random-bytes@1.0.0:
+ resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
+ engines: {node: '>= 0.8'}
+
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -3767,6 +3792,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ uid-safe@2.1.5:
+ resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==}
+ engines: {node: '>= 0.8'}
+
uid2@0.0.4:
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
@@ -6315,6 +6344,8 @@ snapshots:
cookie-signature@1.0.6: {}
+ cookie-signature@1.0.7: {}
+
cookie@0.5.0: {}
cookie@0.6.0: {}
@@ -6630,6 +6661,19 @@ snapshots:
transitivePeerDependencies:
- encoding
+ express-session@1.18.0:
+ dependencies:
+ cookie: 0.6.0
+ cookie-signature: 1.0.7
+ debug: 2.6.9
+ depd: 2.0.0
+ on-headers: 1.0.2
+ parseurl: 1.3.3
+ safe-buffer: 5.2.1
+ uid-safe: 2.1.5
+ transitivePeerDependencies:
+ - supports-color
+
express@4.18.2:
dependencies:
accepts: 1.3.8
@@ -7840,6 +7884,8 @@ snapshots:
dependencies:
ee-first: 1.1.1
+ on-headers@1.0.2: {}
+
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -7915,6 +7961,10 @@ snapshots:
dependencies:
passport-oauth2: 1.8.0
+ passport-github2@0.1.12:
+ dependencies:
+ passport-oauth2: 1.8.0
+
passport-google-oauth20@2.0.0:
dependencies:
passport-oauth2: 1.8.0
@@ -8066,6 +8116,8 @@ snapshots:
quick-format-unescaped@4.0.4: {}
+ random-bytes@1.0.0: {}
+
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@@ -8546,6 +8598,10 @@ snapshots:
typescript@5.3.3: {}
+ uid-safe@2.1.5:
+ dependencies:
+ random-bytes: 1.0.0
+
uid2@0.0.4: {}
uid2@1.0.0: {}
diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts
index 030fbc3..a3b3df8 100644
--- a/src/auth/auth.module.ts
+++ b/src/auth/auth.module.ts
@@ -11,6 +11,8 @@ import { LinkedInStrategy } from '../oauth/strategy/linkedin/linkedin.strategy';
import { AppleOAuthStrategyFactory } from '../oauth/factory/apple/apple-strategy.factory';
import { AppleStrategy } from '../oauth/strategy/apple/apple.strategy';
import { MailService } from '../mail/mail.service';
+import { GithubOAuthStrategyFactory } from '../oauth/factory/github/github-strategy.factory';
+import { GithubStrategy } from '../oauth/strategy/github/github.strategy';
@Module({
imports: [
@@ -64,6 +66,14 @@ import { MailService } from '../mail/mail.service';
},
inject: [AppleOAuthStrategyFactory],
},
+ GithubOAuthStrategyFactory,
+ {
+ provide: GithubStrategy,
+ useFactory: (githubOAuthFactory: GithubOAuthStrategyFactory) => {
+ githubOAuthFactory.createOAuthStrategy();
+ },
+ inject: [GithubOAuthStrategyFactory],
+ },
],
})
export class AuthModule {}
diff --git a/src/auth/controller/auth.controller.spec.ts b/src/auth/controller/auth.controller.spec.ts
index 987af42..fe0047b 100644
--- a/src/auth/controller/auth.controller.spec.ts
+++ b/src/auth/controller/auth.controller.spec.ts
@@ -10,6 +10,7 @@ import { AppleOAuthStrategyFactory } from '../../oauth/factory/apple/apple-strat
import { ConfigService } from '@nestjs/config';
import { MailService } from '../../mail/mail.service';
import { mockDeep } from 'jest-mock-extended';
+import { GithubOAuthStrategyFactory } from '../../oauth/factory/github/github-strategy.factory';
describe('AuthController', () => {
let controller: AuthController;
@@ -25,6 +26,7 @@ describe('AuthController', () => {
LinkedInOAuthStrategyFactory,
FacebookOAuthStrategyFactory,
AppleOAuthStrategyFactory,
+ GithubOAuthStrategyFactory,
ConfigService,
MailService,
],
diff --git a/src/auth/controller/auth.controller.ts b/src/auth/controller/auth.controller.ts
index 13d1480..2e99498 100644
--- a/src/auth/controller/auth.controller.ts
+++ b/src/auth/controller/auth.controller.ts
@@ -8,6 +8,7 @@ import {
Param,
Post,
Put,
+ Query,
Req,
Res,
UseGuards,
@@ -33,6 +34,7 @@ import {
} from '@nestjs/swagger';
import { userProperties } from '../../schemas/user.properties';
import { LowercasePipe } from '../../common/pipes/lowercase.pipe';
+import { GithubOAuthStrategyFactory } from '../../oauth/factory/github/github-strategy.factory';
@Controller('auth')
@ApiTags('Auth Controller')
@@ -43,6 +45,7 @@ export class AuthController {
private facebookOAuthStrategyFactory: FacebookOAuthStrategyFactory,
private linkedinOAuthStrategyFactory: LinkedInOAuthStrategyFactory,
private appleOAuthStrategyFactory: AppleOAuthStrategyFactory,
+ private githubOAuthStrategyFactory: GithubOAuthStrategyFactory,
) {}
@Public()
@@ -51,13 +54,14 @@ export class AuthController {
summary: 'Google auth',
description: 'Sign in or sign up with Google',
})
- async googleOAuthLogin(@Res() res) {
+ async googleOAuthLogin(@Res() res, @Query() query, @Req() req) {
if (!this.googleOAuthStrategyFactory.isOAuthEnabled()) {
throw new HttpException(
'Google Auth is not enabled in this environment.',
HttpStatus.BAD_REQUEST,
);
}
+ req.session.app_url = query.app_url;
res.status(302).redirect('/api/auth/google/callback');
}
@@ -65,8 +69,13 @@ export class AuthController {
@Public()
@Get('google/callback')
@UseGuards(AuthGuard('google'))
- async googleOAuthCallback(@Req() req) {
- return await this.authService.handleGoogleOAuthLogin(req);
+ async googleOAuthCallback(@Req() req, @Res() res) {
+ const user = await this.authService.handleGoogleOAuthLogin(req);
+ const host = req.session.app_url;
+
+ res.send(
+ ``,
+ );
}
@Public()
@@ -75,13 +84,14 @@ export class AuthController {
summary: 'Facebook auth',
description: 'Sign in or sign up with Facebook',
})
- async facebookOAuthLogin(@Res() res) {
+ async facebookOAuthLogin(@Res() res, @Query() query, @Req() req) {
if (!this.facebookOAuthStrategyFactory.isOAuthEnabled()) {
throw new HttpException(
'Facebook Auth is not enabled in this environment.',
HttpStatus.BAD_REQUEST,
);
}
+ req.session.app_url = query.app_url;
res.status(302).redirect('/api/auth/facebook/callback');
}
@@ -89,8 +99,14 @@ export class AuthController {
@Public()
@Get('facebook/callback')
@UseGuards(AuthGuard('facebook'))
- async facebookOAuthCallback(@Req() req) {
- return await this.authService.handleFacebookOAuthLogin(req);
+ async facebookOAuthCallback(@Req() req, @Res() res) {
+ const user = await this.authService.handleFacebookOAuthLogin(req);
+
+ const host = req.session.app_url;
+
+ res.send(
+ ``,
+ );
}
@Public()
@@ -99,13 +115,14 @@ export class AuthController {
summary: 'LinkedIn auth',
description: 'Sign in or sign up with LinkedIn',
})
- async linkedinOAuthLogin(@Res() res) {
+ async linkedinOAuthLogin(@Res() res, @Query() query, @Req() req) {
if (!this.linkedinOAuthStrategyFactory.isOAuthEnabled()) {
throw new HttpException(
'LinkedIn Auth is not enabled in this environment.',
HttpStatus.BAD_REQUEST,
);
}
+ req.session.app_url = query.app_url;
res.status(302).redirect('/api/auth/linkedin/callback');
}
@@ -113,8 +130,13 @@ export class AuthController {
@Public()
@Get('linkedin/callback')
@UseGuards(AuthGuard('linkedin'))
- async linkedinOAuthCallback(@Req() req) {
- return await this.authService.handleLinkedInOAuthLogin(req);
+ async linkedinOAuthCallback(@Req() req, @Res() res) {
+ const user = await this.authService.handleLinkedInOAuthLogin(req);
+ const host = req.session.app_url;
+
+ res.send(
+ ``,
+ );
}
@Public()
@@ -123,7 +145,7 @@ export class AuthController {
summary: 'Apple auth',
description: 'Sign in or sign up with Apple',
})
- async appleOAuthLogin(@Res() res) {
+ async appleOAuthLogin(@Res() res, @Query() query, @Req() req) {
if (!this.appleOAuthStrategyFactory.isOAuthEnabled()) {
throw new HttpException(
'Apple Auth is not enabled in this environment.',
@@ -131,14 +153,52 @@ export class AuthController {
);
}
+ req.session.app_url = query.app_url;
+
res.status(302).redirect('/api/auth/apple/callback');
}
@Public()
@Get('apple/callback')
@UseGuards(AuthGuard('apple'))
- async appleOAuthCallback(@Req() req) {
- return await this.authService.handleAppleOAuthLogin(req);
+ async appleOAuthCallback(@Req() req, @Res() res) {
+ const user = await this.authService.handleAppleOAuthLogin(req);
+ const host = req.session.app_url;
+
+ res.send(
+ ``,
+ );
+ }
+
+ @Public()
+ @Get('github')
+ @ApiOperation({
+ summary: 'Github auth',
+ description: 'Sign in or sign up with Github',
+ })
+ async githubOauthLogin(@Res() res, @Query() query, @Req() req) {
+ if (!this.githubOAuthStrategyFactory.isOAuthEnabled()) {
+ throw new HttpException(
+ 'Github Auth is not enabled in this environment.',
+ HttpStatus.BAD_REQUEST,
+ );
+ }
+
+ req.session.app_url = query.app_url;
+
+ res.status(302).redirect('/api/auth/github/callback');
+ }
+
+ @Public()
+ @Get('github/callback')
+ @UseGuards(AuthGuard('github'))
+ async githubOAuthCallback(@Req() req, @Res() res) {
+ const user = await this.authService.handleGithubOAuthLogin(req);
+ const host = req.session.app_url;
+
+ res.send(
+ ``,
+ );
}
@Public()
diff --git a/src/auth/service/auth.service.spec.ts b/src/auth/service/auth.service.spec.ts
index e8a860f..1aa779d 100644
--- a/src/auth/service/auth.service.spec.ts
+++ b/src/auth/service/auth.service.spec.ts
@@ -9,6 +9,7 @@ import { FacebookOAuthStrategyFactory } from '../../oauth/factory/facebook/faceb
import { AppleOAuthStrategyFactory } from '../../oauth/factory/apple/apple-strategy.factory';
import { ConfigService } from '@nestjs/config';
import { MailService } from '../../mail/mail.service';
+import { GithubOAuthStrategyFactory } from '../..//oauth/factory/github/github-strategy.factory';
describe('AuthService', () => {
let service: AuthService;
@@ -23,6 +24,7 @@ describe('AuthService', () => {
LinkedInOAuthStrategyFactory,
FacebookOAuthStrategyFactory,
AppleOAuthStrategyFactory,
+ GithubOAuthStrategyFactory,
ConfigService,
MailService,
],
diff --git a/src/auth/service/auth.service.ts b/src/auth/service/auth.service.ts
index c825084..0842425 100644
--- a/src/auth/service/auth.service.ts
+++ b/src/auth/service/auth.service.ts
@@ -57,9 +57,10 @@ export class AuthService {
const user = await this.createUserIfNotExists(
email,
+ AuthType.GOOGLE,
name,
profilePictureUrl,
- AuthType.GOOGLE,
+ false,
);
const token = await this.generateToken(user);
@@ -81,6 +82,7 @@ export class AuthService {
AuthType.FACEBOOK,
displayName,
profilePictureUrl,
+ false,
);
const token = await this.generateToken(user);
@@ -130,6 +132,23 @@ export class AuthService {
};
}
+ async handleGithubOAuthLogin(req: any) {
+ const { email, name, login, avatar_url } = req.user._json;
+ const user = await this.createUserIfNotExists(
+ email || login,
+ AuthType.GITHUB,
+ name,
+ avatar_url,
+ );
+
+ const token = await this.generateToken(user);
+
+ return {
+ ...user,
+ token,
+ };
+ }
+
async resendEmailVerificationCode(email: string) {
const user = await this.findUserByEmail(email);
if (!user) {
@@ -214,6 +233,7 @@ export class AuthService {
name: name,
profilePictureUrl: profilePictureUrl,
authType,
+ isEmailVerified: authType !== AuthType.EMAIL,
settings: {
create: {},
},
@@ -232,6 +252,7 @@ export class AuthService {
} else if (!user.isEmailVerified) {
await this.sendEmailVerificationCode(email);
}
+
return user;
}
diff --git a/src/main.ts b/src/main.ts
index 1c6da3a..7d3e2b8 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -3,6 +3,7 @@ import { AppModule } from './app/app.module';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { json } from 'express';
+import * as session from 'express-session';
function initializeSwagger(app: any) {
const config = new DocumentBuilder()
@@ -18,6 +19,8 @@ function initializeSwagger(app: any) {
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const globalPrefix = 'api';
+ app.use(session({ secret: process.env.SESSION_SECRET }));
+
app.setGlobalPrefix(globalPrefix);
app.enableCors();
app.useGlobalPipes(
diff --git a/src/oauth/factory/github/github-strategy.factory.ts b/src/oauth/factory/github/github-strategy.factory.ts
new file mode 100644
index 0000000..2822e97
--- /dev/null
+++ b/src/oauth/factory/github/github-strategy.factory.ts
@@ -0,0 +1,44 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { OAuthStrategyFactory } from '../oauth-strategy.factory';
+import { GithubStrategy } from '../../strategy/github/github.strategy';
+
+@Injectable()
+export class GithubOAuthStrategyFactory implements OAuthStrategyFactory {
+ private readonly clientID: string;
+ private readonly clientSecret: string;
+ private readonly callbackURL: string;
+
+ constructor(private readonly configService: ConfigService) {
+ this.clientID = this.configService.get('GITHUB_CLIENT_ID');
+ this.clientSecret = this.configService.get('GITHUB_CLIENT_SECRET');
+ this.callbackURL = this.configService.get('GITHUB_CALLBACK_URL');
+ }
+
+ public isOAuthEnabled(): boolean {
+ return Boolean(this.clientID && this.clientSecret && this.callbackURL);
+ }
+
+ public isSocialAccountLinkEnabled(): boolean {
+ return false;
+ }
+
+ public createOAuthStrategy(): GithubStrategy | null {
+ if (this.isOAuthEnabled()) {
+ return new GithubStrategy(
+ this.clientID,
+ this.clientSecret,
+ this.callbackURL,
+ ) as GithubStrategy;
+ } else {
+ Logger.warn('Github Auth is not enabled in this environment.');
+ return null;
+ }
+ }
+
+ public createSocialAccountLinkStrategy<
+ GithubStrategy,
+ >(): GithubStrategy | null {
+ return null;
+ }
+}
diff --git a/src/oauth/strategy/github/github.strategy.ts b/src/oauth/strategy/github/github.strategy.ts
new file mode 100644
index 0000000..fed8811
--- /dev/null
+++ b/src/oauth/strategy/github/github.strategy.ts
@@ -0,0 +1,22 @@
+import { Injectable } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+import { Profile, Strategy } from 'passport-github2';
+@Injectable()
+export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
+ constructor(clientID: string, clientSecret: string, callbackURL: string) {
+ super({
+ clientID,
+ clientSecret,
+ callbackURL,
+ scope: ['public_profile'],
+ });
+ }
+
+ async validate(
+ accessToken: string,
+ refreshToken: string,
+ profile: Profile,
+ ): Promise {
+ return profile;
+ }
+}
diff --git a/src/prisma/migrations/20240531152547_github_oauth/migration.sql b/src/prisma/migrations/20240531152547_github_oauth/migration.sql
new file mode 100644
index 0000000..45f2ad3
--- /dev/null
+++ b/src/prisma/migrations/20240531152547_github_oauth/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "AuthType" ADD VALUE 'GITHUB';
diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma
index f91510f..b8c149d 100644
--- a/src/prisma/schema.prisma
+++ b/src/prisma/schema.prisma
@@ -12,6 +12,7 @@ enum AuthType {
APPLE
FACEBOOK
LINKEDIN
+ GITHUB
EMAIL
EXTERNAL
}