diff --git a/README.md b/README.md index 1e3e6b2..4bca939 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ You will need: - Ports are assumed to not have been changed from the defaults - A [Discord application](https://discord.com/developers/applications) for authentication - Set the redirect URL to `(your domain)/oauth/discord/redirect` +- Both a [GitHub](https://github.com/settings/developers) OAuth app and personal access token + - The OAuth app will be used for authentication, and the PAT will be used for inviting users automatically + - Set the redirect URL to `(your domain)/oauth/github/redirect` ### Cloning & config @@ -36,6 +39,11 @@ After cloning, create an `.env.local` with the following contents (in `key=value - `DISCORD_ALLOWED_GUILDS`: a comma separated list of guild IDs - Users must be in one of these guilds to register with gluestick - Enable "Advanced > Developer Mode" in your Discord client to copy IDs +- `GITHUB_CLIENT_ID`: the client ID from your GitHub OAuth app +- `GITHUB_CLIENT_SECRET`: the client secret from your GitHub OAuth app +- `GITHUB_TOKEN`: a personal access token, with the ability to modify organization members +- `GITHUB_ORG`: an organization name + - Users must be in this organization to register with gluestick - `LDAP_HOST`: the IP address or hostname of your LLDAP server - `LDAP_DC`: your LDAP dc - `LDAP_BIND_USER`: the bind user of your LLDAP server @@ -53,7 +61,12 @@ DISCORD_ALLOWED_GUILDS=986268106416611368,805978396974514206 LDAP_HOST=auth LDAP_DC=dc=n2,dc=pm LDAP_BIND_USER=admin -LDAP_BIND_PASSWORD=redacted +LDAP_BIND_PASSWORD=redactedd + +GITHUB_CLIENT_ID=2c946381e680acfa5e4a +GITHUB_CLIENT_SECRET=redacted +GITHUB_TOKEN=redacted +GITHUB_ORG=n2pm BASE_DOMAIN=https://gluestick.n2.pm/ ``` diff --git a/environment.d.ts b/environment.d.ts index c9d02e2..f5efa40 100644 --- a/environment.d.ts +++ b/environment.d.ts @@ -1,4 +1,13 @@ +import { PrismaClient } from "@prisma/client"; +import { Client as LDAPClient } from "ldapts"; +import { ApolloClient, InMemoryCache } from "@apollo/client"; +import { LLDAPAuthResponse } from "@/ldap"; + declare global { + var prisma: PrismaClient | undefined; + var ldapClient: LDAPClient | undefined; + var authResponse: LLDAPAuthResponse | undefined; + namespace NodeJS { interface ProcessEnv { DISCORD_CLIENT_ID: string; @@ -10,6 +19,11 @@ declare global { LDAP_BIND_USER: string; LDAP_BIND_PASSWORD: string; + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; + GITHUB_TOKEN: string; + GITHUB_ORG: string; + BASE_DOMAIN: string; } } diff --git a/package-lock.json b/package-lock.json index 28cff83..888b8d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "dotenv": "^16.0.3", "eslint": "8.39.0", "eslint-config-next": "13.3.1", + "formik": "^2.2.9", "graphql": "^16.6.0", "ldapts": "^4.2.5", "next": "13.3.1", @@ -25,7 +26,8 @@ "react-dom": "18.2.0", "sharp": "^0.32.0", "typescript": "5.0.4", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "yup": "^1.1.1" }, "devDependencies": { "@graphql-codegen/cli": "^3.3.1", @@ -3464,6 +3466,14 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -4425,6 +4435,34 @@ "is-callable": "^1.1.3" } }, + "node_modules/formik": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", + "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", + "funding": [ + { + "type": "individual", + "url": "https://opencollective.com/formik" + } + ], + "dependencies": { + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^1.10.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/formik/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -5716,8 +5754,12 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -6757,6 +6799,11 @@ "react-is": "^16.13.1" } }, + "node_modules/property-expr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", + "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -6861,6 +6908,11 @@ "react": "^18.2.0" } }, + "node_modules/react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -7649,6 +7701,11 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "node_modules/tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", @@ -7658,6 +7715,11 @@ "globrex": "^0.1.2" } }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "node_modules/title-case": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", @@ -7699,6 +7761,11 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -8186,6 +8253,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.1.1.tgz", + "integrity": "sha512-KfCGHdAErqFZWA5tZf7upSUnGKuTOnsI3hUsLr7fgVtx+DK04NPV01A68/FslI4t3s/ZWpvXJmgXhd7q6ICnag==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zen-observable": { "version": "0.8.15", "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", @@ -10717,6 +10806,11 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==" + }, "defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -11460,6 +11554,27 @@ "is-callable": "^1.1.3" } }, + "formik": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", + "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", + "requires": { + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -12371,8 +12486,12 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, "lodash.merge": { "version": "4.6.2", @@ -13100,6 +13219,11 @@ "react-is": "^16.13.1" } }, + "property-expr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", + "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -13174,6 +13298,11 @@ "scheduler": "^0.23.0" } }, + "react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -13749,6 +13878,11 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", @@ -13758,6 +13892,11 @@ "globrex": "^0.1.2" } }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "title-case": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", @@ -13790,6 +13929,11 @@ "is-number": "^7.0.0" } }, + "toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14138,6 +14282,24 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, + "yup": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.1.1.tgz", + "integrity": "sha512-KfCGHdAErqFZWA5tZf7upSUnGKuTOnsI3hUsLr7fgVtx+DK04NPV01A68/FslI4t3s/ZWpvXJmgXhd7q6ICnag==", + "requires": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + }, + "dependencies": { + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" + } + } + }, "zen-observable": { "version": "0.8.15", "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", diff --git a/package.json b/package.json index 60db58b..a837096 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dotenv": "^16.0.3", "eslint": "8.39.0", "eslint-config-next": "13.3.1", + "formik": "^2.2.9", "graphql": "^16.6.0", "ldapts": "^4.2.5", "next": "13.3.1", @@ -29,7 +30,8 @@ "react-dom": "18.2.0", "sharp": "^0.32.0", "typescript": "5.0.4", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "yup": "^1.1.1" }, "devDependencies": { "@graphql-codegen/cli": "^3.3.1", diff --git a/prisma/migrations/20230426210117_add_github/migration.sql b/prisma/migrations/20230426210117_add_github/migration.sql new file mode 100644 index 0000000..a8a7d32 --- /dev/null +++ b/prisma/migrations/20230426210117_add_github/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "GitHubAuth" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" INTEGER NOT NULL, + "accessToken" TEXT NOT NULL, + CONSTRAINT "GitHubAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "GitHubAuth_userId_key" ON "GitHubAuth"("userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39d297e..6699e0c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model User { authTicket AuthTicket? discordAuth DiscordAuth? + githubAuth GitHubAuth? } model DiscordAuth { @@ -34,3 +35,12 @@ model DiscordAuth { refreshToken String expiresAt DateTime } + +model GitHubAuth { + id Int @id + + user User @relation(fields: [userId], references: [id]) + userId Int @unique + + accessToken String +} diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index ea49f13..52b03ef 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -2,6 +2,7 @@ import * as ldap from "@/ldap"; import prisma from "@/prisma"; import { getUser } from "@/auth"; import { getDiscordAvatar } from "@/app/oauth/discord/oauth"; +import { getGitHubAvatar } from "@/app/oauth/github/oauth"; import { getLogger } from "@/logger"; type RequestBody = { @@ -64,6 +65,16 @@ export async function POST(request: Request) { ); } + if (username.length < 1) { + return new Response( + JSON.stringify({ + ok: false, + error: "usernameShort" + }), + { status: 400 } + ); + } + if (password.length < 12) { return new Response( JSON.stringify({ @@ -89,16 +100,6 @@ export async function POST(request: Request) { } } - const discordAuth = await prisma.discordAuth.findFirst({ - where: { - userId: user.id - } - }); - - if (discordAuth !== null && avatarBuf === undefined) { - avatarBuf = await getDiscordAvatar(discordAuth.accessToken); - } - const users = await ldap.getUsers(); for (const user of users) { if (user.id.toLowerCase() === username.toLowerCase()) { diff --git a/src/app/globals.css b/src/app/globals.css index ae33bf5..522ebeb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -74,6 +74,11 @@ label { color var(--theme-transition); } +input:disabled, button:disabled { + opacity: 0.5; + cursor: not-allowed !important; +} + input, button { font: inherit; diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index 6fa3ee6..dd04a61 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -1,49 +1,77 @@ "use client"; +import Input from "@/components/Input"; +import PrettyForm from "@/components/PrettyForm"; +import { LoginFormValues, loginSchema } from "@/schemas"; +import { Form, Formik, FormikHelpers, FormikValues } from "formik"; import React from "react"; -// TODO: use input from register & un programmer art this export default function LoginForm() { - const usernameRef = React.useRef(null); - const passwordRef = React.useRef(null); + const [globalError, setGlobalError] = React.useState(null); + + async function handleFormSubmit( + { username, password }: LoginFormValues, + { setSubmitting }: FormikHelpers + ) { + setSubmitting(true); + + const req = await fetch("/api/login", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username, + password + }) + }); + + try { + const res: { + ok: boolean; + error?: string; + ticket: string; + } = await req.json(); + + if (res.ok) { + document.cookie = `ticket=${res.ticket}; path=/;`; + window.location.href = "/me"; + } else { + // only error is invalidCredentials, I am lazy + setGlobalError("Invalid credentials."); + } + } catch (err) { + console.error(err); + setGlobalError("shits fucked up yo"); + setSubmitting(false); + } + } return ( -
{ - e.preventDefault(); - - const username = usernameRef.current?.value ?? ""; - const password = passwordRef.current?.value ?? ""; - - const req = await fetch("/api/login", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - username, - password - }) - }); - - if (req.status === 200) { - const res: { ticket: string } = await req.json(); - document.cookie = `ticket=${res.ticket}; path=/;`; - window.location.href = "/me"; - } else { - // todo error handling lol - } - }} - > - - - -
+ + + {() => ( +
+ + + +
+ )} +
+
); } diff --git a/src/app/me/AboutMe.tsx b/src/app/me/AboutMe.tsx index 5c44186..df3a668 100644 --- a/src/app/me/AboutMe.tsx +++ b/src/app/me/AboutMe.tsx @@ -278,6 +278,21 @@ export default function AboutMe({ info }: { info: UserInfo }) { /> + +
+ +
+ { + document.cookie = + "ticket=; expires=" + new Date().toUTCString() + "; path=/"; + window.location.href = "/"; + }} + /> +
); } diff --git a/src/app/me/page.tsx b/src/app/me/page.tsx index 046c7e1..70f611b 100644 --- a/src/app/me/page.tsx +++ b/src/app/me/page.tsx @@ -1,19 +1,14 @@ import { getUser } from "@/auth"; import { getUserInfo } from "@/ldap"; import AboutMe from "./AboutMe"; +import { redirect } from "next/navigation"; export default async function Page() { const user = await getUser(); - if (!user) { - window.location.href = "/login"; - return; - } + if (!user) redirect("/login"); const info = await getUserInfo(user); - if (info === null) { - window.location.href = "/login"; - return; - } + if (info === null) redirect("/login"); return ; } diff --git a/src/app/oauth/discord/oauth.ts b/src/app/oauth/discord/oauth.ts index ea8ef3d..9688726 100644 --- a/src/app/oauth/discord/oauth.ts +++ b/src/app/oauth/discord/oauth.ts @@ -11,6 +11,8 @@ export type DiscordAccessTokenResponse = { export type DiscordUserResponse = { id: string; avatar: string | null; + username: string; + email: string | null; }; export type DiscordGuildResponse = { @@ -21,14 +23,14 @@ export function discordRedirectUri() { return `${process.env.BASE_DOMAIN}oauth/discord/redirect`; } -export async function getDiscordID(token: string) { +export async function getDiscordUser(token: string) { const req = await fetch("https://discord.com/api/users/@me", { headers: { Authorization: `Bearer ${token}` } }); const res: DiscordUserResponse = await req.json(); - return res.id; + return res; } export async function getDiscordGuilds(token: string) { @@ -51,8 +53,5 @@ export async function getDiscordAvatar(token: string) { const res: DiscordUserResponse = await req.json(); if (res.avatar === null) return null; const file = `https://cdn.discordapp.com/avatars/${res.id}/${res.avatar}.png`; - - const avatarReq = await fetch(file); - const avatarBuffer = await avatarReq.arrayBuffer(); - return Buffer.from(avatarBuffer); + return file; } diff --git a/src/app/oauth/discord/redirect/route.ts b/src/app/oauth/discord/redirect/route.ts index d699ca9..46dd533 100644 --- a/src/app/oauth/discord/redirect/route.ts +++ b/src/app/oauth/discord/redirect/route.ts @@ -2,8 +2,9 @@ import { URLSearchParams } from "url"; import { discordRedirectUri, DiscordAccessTokenResponse, - getDiscordID, - getDiscordGuilds + getDiscordGuilds, + getDiscordUser, + getDiscordAvatar } from "../oauth"; import { cookies } from "next/dist/client/components/headers"; import prisma from "@/prisma"; @@ -54,14 +55,14 @@ export async function GET(request: Request) { let tokenBody: DiscordAccessTokenResponse = await tokenResponse.json(); - const id = await getDiscordID(tokenBody.access_token); + const discordUser = await getDiscordUser(tokenBody.access_token); const guilds = await getDiscordGuilds(tokenBody.access_token); const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? []; let allowed = false; for (const guild of allowedGuilds) if (guilds.includes(guild)) allowed = true; if (!allowed) { - logger.info(`user ${id} tried to sign up`); + logger.info({ id: discordUser.id }, "user tried to sign up"); return new Response("not permitted to register account", { status: 403 }); } @@ -72,10 +73,10 @@ export async function GET(request: Request) { const discordAuth = await prisma.discordAuth.upsert({ where: { - id + id: discordUser.id }, create: { - id, + id: discordUser.id, accessToken: tokenBody.access_token, refreshToken: tokenBody.refresh_token, expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000), @@ -157,11 +158,18 @@ export async function GET(request: Request) { }); } + const avatarUrl = await getDiscordAvatar(tokenBody.access_token); + + const query = new URLSearchParams(); + query.append("username", discordUser.username); + query.append("email", discordUser.email ?? ""); + query.append("avatar", avatarUrl ?? ""); + return new Response(null, { status: 302, headers: { "Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`, - Location: "/register" + Location: "/register?" + query.toString() } }); } diff --git a/src/app/oauth/github/login/route.ts b/src/app/oauth/github/login/route.ts new file mode 100644 index 0000000..af39915 --- /dev/null +++ b/src/app/oauth/github/login/route.ts @@ -0,0 +1,22 @@ +import { v4 } from "uuid"; + +export async function GET(request: Request) { + let url = `https://github.com/login/oauth/authorize`; + let state = v4(); + + let params = new URLSearchParams(); + params.set("client_id", process.env.GITHUB_CLIENT_ID); + params.set("scope", "user"); + params.set("state", state); + params.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`); + + url += `?${params.toString()}`; + + return new Response(null, { + status: 302, + headers: { + Location: url, + "Set-Cookie": `state=${state}; Path=/;` + } + }); +} diff --git a/src/app/oauth/github/oauth.ts b/src/app/oauth/github/oauth.ts new file mode 100644 index 0000000..4cc4d15 --- /dev/null +++ b/src/app/oauth/github/oauth.ts @@ -0,0 +1,41 @@ +export type GitHubAccessTokenResponse = { + access_token: string; + scope: string; + token_type: string; +}; + +export type GitHubUserResponse = { + login: string; + id: number; + avatar_url: string; + email: string; +}; + +export async function getGitHubUser(token: string) { + const req = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${token}` + } + }); + const res: GitHubUserResponse = await req.json(); + return res; +} + +export async function checkInOrg(username: string) { + const req = await fetch( + `https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`, + { + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}` + } + } + ); + + const res: GitHubUserResponse[] = await req.json(); + return res.some((user) => user.login === username); +} + +export async function getGitHubAvatar(token: string) { + const user = await getGitHubUser(token); + return user.avatar_url; +} diff --git a/src/app/oauth/github/redirect/route.ts b/src/app/oauth/github/redirect/route.ts new file mode 100644 index 0000000..e52d184 --- /dev/null +++ b/src/app/oauth/github/redirect/route.ts @@ -0,0 +1,153 @@ +import { getLogger } from "@/logger"; +import { cookies } from "next/dist/client/components/headers"; +import { + checkInOrg, + getGitHubAvatar, + getGitHubUser, + GitHubAccessTokenResponse +} from "../oauth"; +import prisma from "@/prisma"; +import * as ldap from "@/ldap"; +import { v4 } from "uuid"; + +const logger = getLogger("/oauth/github/redirect"); + +export async function GET(request: Request) { + let url = new URL(request.url); + let code = url.searchParams.get("code"); + let state = url.searchParams.get("state"); + + if (code === null || state === null) { + logger.info("request made with missing code/state"); + return new Response("missing code/state", { status: 400 }); + } + + const cookieStore = cookies(); + let cookieState = cookieStore.get("state"); + // prevent forgery + if (cookieState?.value !== state) { + logger.info( + "request made with invalid state - someone attempting forgery?" + ); + return new Response("state is invalid", { status: 400 }); + } + + let query = new URLSearchParams(); + query.set("client_id", process.env.GITHUB_CLIENT_ID); + query.set("client_secret", process.env.GITHUB_CLIENT_SECRET); + query.set("code", code); + query.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`); + + let tokenUrl = `https://github.com/login/oauth/access_token?${query.toString()}`; + let tokenResponse = await fetch(tokenUrl, { + method: "POST", + headers: { + Accept: "application/json" + } + }); + + if (!tokenResponse.ok) { + logger.error("baby"); + throw "baby"; + } + + let resp: GitHubAccessTokenResponse = await tokenResponse.json(); + let accessToken = resp.access_token; + const githubUser = await getGitHubUser(accessToken); + const inOrg = await checkInOrg(githubUser.login); + + if (!inOrg) { + logger.info({ id: githubUser.login }, "user tried to sign up"); + return new Response("not permitted to register account", { status: 403 }); + } + + const githubAuth = await prisma.gitHubAuth.upsert({ + where: { id: githubUser.id }, + create: { + id: githubUser.id, + accessToken, + user: { create: { username: null } } + }, + update: { accessToken } + }); + + const user = await prisma.user.findFirst({ + where: { + id: githubAuth.userId + } + }); + + // check if user got deleted from ldap, same as /api/register + if ( + user !== null && + user.username !== null && + !(await ldap.checkUserExists(user.username)) + ) { + logger.warn( + { username: user.username }, + "user doesn't exist in ldap anymore" + ); + user.username = null; + await prisma.user.update({ + where: { + id: user.id + }, + data: { + username: null + } + }); + } + + const authTicket = await prisma.authTicket.upsert({ + where: { + userId: user!.id + }, + create: { + userId: user!.id, + ticket: v4(), + expiresAt: new Date(Date.now() + 86400000) + }, + update: { + ticket: v4(), + expiresAt: new Date(Date.now() + 86400000) + } + }); + + await prisma.user.update({ + where: { + id: user!.id + }, + data: { + authTicket: { + connect: { + id: authTicket.id + } + } + } + }); + + if (user?.username !== null) { + return new Response(null, { + status: 302, + headers: { + "Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`, + Location: "/me" + } + }); + } + + const avatarUrl = await getGitHubAvatar(accessToken); + + const query2 = new URLSearchParams(); + query2.append("username", githubUser.login); + query2.append("email", githubUser.email); + query2.append("avatar", avatarUrl); + + return new Response(null, { + status: 302, + headers: { + "Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`, + Location: "/register?" + query2.toString() + } + }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 8e00a1e..f6a80df 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -14,6 +14,7 @@ export default function Home() { > login register (discord) + register (github)

); diff --git a/src/app/register/RegisterForm.module.css b/src/app/register/RegisterForm.module.css index dc23419..f3b0ba8 100644 --- a/src/app/register/RegisterForm.module.css +++ b/src/app/register/RegisterForm.module.css @@ -2,7 +2,7 @@ max-width: 500px; } -.form input[type="submit"] { +.form *[type="submit"] { padding: 1rem 1.5rem; font-size: 140%; background: var(--bg-dark); @@ -18,11 +18,6 @@ margin: 2rem 0; } -.buttonContainer input:disabled { - cursor: not-allowed; - color: var(--fg-dark); -} - .formRow { margin: 1rem 0; } diff --git a/src/app/register/RegisterForm.tsx b/src/app/register/RegisterForm.tsx index 6da3ad1..0367110 100644 --- a/src/app/register/RegisterForm.tsx +++ b/src/app/register/RegisterForm.tsx @@ -1,207 +1,170 @@ "use client"; -import React, { InputHTMLAttributes } from "react"; -import { HTMLInputTypeAttribute } from "react"; +import React from "react"; import styles from "./RegisterForm.module.css"; +import { Form, Formik, FormikHelpers, yupToFormErrors } from "formik"; +import { RegisterFormValues, registerSchema } from "@/schemas"; +import { useRouter } from "next/navigation"; +import { fileAsBase64 } from "@/forms"; +import Input from "@/components/Input"; +import PrettyForm from "@/components/PrettyForm"; +import HugeSubmit from "@/components/HugeSubmit"; type RegisterResponse = { ok: boolean; error?: string; }; -type InputProps = { - label: string; - name: string; - hint?: string; - type: HTMLInputTypeAttribute; - placeholder?: string; - error?: string; -} & InputHTMLAttributes; +export default function RegisterForm({ + initialDisplayName, + initialEmail, + initialAvatarBase64 +}: { + initialDisplayName?: string; + initialEmail?: string; + initialAvatarBase64?: string; +}) { + const [globalError, setGlobalError] = React.useState(null); + const router = useRouter(); -const Input = React.forwardRef((props, ref) => { - return ( -
- - - {props.error != null ? ( -

{props.error}

- ) : ( -

{props.hint}

- )} -
- ); -}); -Input.displayName = "Input"; - -async function fileAsBase64(f: File) { - const reader = new FileReader(); - reader.readAsArrayBuffer(f); - return new Promise((resolve, reject) => { - reader.onload = () => { - const result = reader.result as ArrayBuffer; - const buffer = Buffer.from(result); - resolve(buffer.toString("base64")); - }; - reader.onerror = () => reject(reader.error); + const [initialValues, setInitialValues] = React.useState({ + username: "", + displayName: initialDisplayName ?? "", + email: initialEmail ?? "", + password: "", + confirmPassword: "", + avatar: undefined }); -} -export default function RegisterForm() { - const usernameRef = React.useRef(null); - const displayNameRef = React.useRef(null); - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); - const confirmPasswordRef = React.useRef(null); - const avatarRef = React.useRef(null); - const submitRef = React.useRef(null); + async function handleFormSubmit( + { avatar, username, displayName, email, password }: RegisterFormValues, + { setFieldError, setSubmitting }: FormikHelpers + ) { + setSubmitting(true); - const [usernameTaken, setUsernameTaken] = React.useState(false); - const [passwordMismatch, setPasswordMismatch] = React.useState(false); - const [avatarBig, setAvatarBig] = React.useState(false); + let avatarBase64 = avatar != null ? await fileAsBase64(avatar) : null; + if (avatarBase64 == null && initialAvatarBase64 != null) { + avatarBase64 = initialAvatarBase64.split(",")[1]; + } + + const resp = await fetch(`/api/register`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username, + displayName, + email, + password, + avatarBase64 + }) + }); + + try { + const res: RegisterResponse = await resp.json(); + + if (res.ok) { + router.replace("/me"); + } else { + if (res.error !== null) { + switch (res.error) { + case "avatarBig": + setFieldError( + "avatar", + "avatar was too big, but only the server caught you what the fuck are you doing!!" + ); + break; + + case "usernameTaken": + setFieldError("username", "Username is already taken."); + break; + } + } + } + } catch (err) { + console.error(err); + setGlobalError("you done fucked up kiddo"); + } + + setSubmitting(false); + } return ( -
-
{ - e.preventDefault(); - - const [username, displayName, email, password, confirmPassword] = [ - usernameRef, - displayNameRef, - emailRef, - passwordRef, - confirmPasswordRef - ].map((ref) => ref.current?.value); - const avatar = avatarRef.current?.files?.[0]; - - const avatarBase64 = - avatar != null ? await fileAsBase64(avatar!) : null; - - if (password !== confirmPassword) { - setPasswordMismatch(true); - return; - } - - if (avatar != null && avatar?.size > 1_000_000) { - setAvatarBig(true); - return; - } - - submitRef.current!.disabled = true; - const req = await fetch(`/api/register`, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - username, - displayName, - email, - password, - avatarBase64 - }) - }); - submitRef.current!.disabled = false; - - if (req.status === 500) { - // something real bad fucked up - return; - } - - try { - const res: RegisterResponse = await req.json(); - - if (res.ok) { - window.location.href = "/me"; - } else { - if (res.error !== null) { - switch (res.error) { - case "avatarBig": - setAvatarBig(true); - break; - - case "usernameTaken": - setUsernameTaken(true); - break; - } - } - } - } catch { - console.error(req); - } - }} + + - + {({ isSubmitting }) => ( + + - + - + - + - + - + { + const file = event.currentTarget.files?.[0]; + if (file != null) { + form.setFieldValue("avatar", file); + } + }} + /> -
- -
- -
+
+ +
+ + )} + + ); } diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 85353b6..4c76191 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -1,18 +1,61 @@ import { cookies } from "next/dist/client/components/headers"; import styles from "@/app/page.module.css"; import RegisterForm from "./RegisterForm"; +import { redirect, useRouter } from "next/navigation"; +import { ensureJpg } from "@/image"; -export default function Page() { +function avatarUrlAllowed(url: URL): boolean { + let notGithub = url.hostname !== "avatars.githubusercontent.com"; + let notDiscord = url.hostname !== "cdn.discordapp.com"; + + if (!notDiscord && !url.pathname.startsWith("/avatars")) return false; + return !(notGithub && notDiscord); +} + +export default async function Page({ + searchParams +}: { + searchParams: { + displayName?: string; + email?: string; + avatar?: string; + }; +}) { const cookieStore = cookies(); const ticket = cookieStore.get("ticket"); + if (ticket === null) { - window.location.href = "/"; - return; + redirect("/"); + } + + let initialAvatarBase64 = undefined; + if (searchParams.avatar != null && searchParams.avatar !== "") { + const url = new URL(searchParams.avatar); + + if (avatarUrlAllowed(url)) { + return

fuck off

; + } + + const req = await fetch(searchParams.avatar); + const blob = await req.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + try { + initialAvatarBase64 = + "data:image/jpeg;base64," + (await ensureJpg(buffer)); + } catch (e) { + console.error(e); + } } return (
- +
); } diff --git a/src/auth.ts b/src/auth.ts index acaefd7..f32e47e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -9,14 +9,14 @@ const logger = getLogger("auth.ts"); export async function getUser() { const cookieStore = cookies(); const cookieTicket = cookieStore.get("ticket"); - if (cookieTicket === null) return null; + if (cookieTicket == null) return null; const ticket = await prisma.authTicket.findFirst({ where: { ticket: cookieTicket?.value } }); - if (ticket === null) return null; + if (ticket == null) return null; const user = await prisma.user.findFirst({ where: { diff --git a/src/components/HugeSubmit.module.css b/src/components/HugeSubmit.module.css new file mode 100644 index 0000000..5e88983 --- /dev/null +++ b/src/components/HugeSubmit.module.css @@ -0,0 +1,10 @@ +.hugeSubmit { + padding: 1rem 1.5rem; + font-size: 140%; + font-weight: 600; +} + +.hugeSubmit:disabled { + cursor: not-allowed; + color: var(--fg-dark); +} diff --git a/src/components/HugeSubmit.tsx b/src/components/HugeSubmit.tsx new file mode 100644 index 0000000..446df23 --- /dev/null +++ b/src/components/HugeSubmit.tsx @@ -0,0 +1,8 @@ +import React, { InputHTMLAttributes } from "react"; +import styles from "./HugeSubmit.module.css"; + +export default function HugeSubmit( + props: InputHTMLAttributes +) { + return ; +} diff --git a/src/components/Input.module.css b/src/components/Input.module.css new file mode 100644 index 0000000..4bf0f8b --- /dev/null +++ b/src/components/Input.module.css @@ -0,0 +1,42 @@ +.buttonContainer { + display: flex; + justify-content: center; + margin: 2rem 0; +} + +.buttonContainer input:disabled { + cursor: not-allowed; + color: var(--fg-dark); +} + +.formRow { + margin: 1rem 0; +} + +.formRow label { + display: block; + font-variant: all-small-caps; + font-size: 105%; +} + +.formRow input { + padding: 0.5em 1em; + border: none; + border-radius: 0.15rem; + margin: 0.5rem 0; + width: 250px; + display: block; + background: var(--bg-dark); +} + +.hint { + color: var(--fg-dark); + font-size: 80%; + transition: color var(--theme-transition); +} + +.error { + color: var(--error); + font-size: 80%; + transition: color var(--theme-transition); +} diff --git a/src/components/Input.tsx b/src/components/Input.tsx new file mode 100644 index 0000000..c5c79c5 --- /dev/null +++ b/src/components/Input.tsx @@ -0,0 +1,63 @@ +import { Field, FieldProps, FieldAttributes, FormikProps } from "formik"; +import React from "react"; +import styles from "./Input.module.css"; + +type CustomInputProps = { + customOnChange?: ( + event: React.ChangeEvent, + form: FormikProps + ) => void; +}; + +export default function Input( + props: CustomInputProps & FieldAttributes<{ hint?: string; label: string }> +) { + const generatedId = React.useId(); + + return ( +
+ + + {({ field, meta, form }: FieldProps) => { + let textAfterField = + meta.touched && meta.error ? ( +

{meta.error}

+ ) : ( + props.hint &&

{props.hint}

+ ); + + // in React is always uncontrolled, so we have to hardcode + // the value to "" if it's a file picker + const inputFields = + props.type === "file" + ? (() => { + let clonedField = Object.assign({}, field); + delete clonedField.value; + return clonedField; + })() + : field; + + return ( + <> + { + console.log(event); + if (props.customOnChange) { + console.log("using custom on change"); + props.customOnChange(event, form); + } else { + form.setFieldValue(field.name, event.currentTarget.value); + } + }} + /> + {textAfterField} + + ); + }} +
+
+ ); +} diff --git a/src/components/PrettyForm.module.css b/src/components/PrettyForm.module.css new file mode 100644 index 0000000..763080b --- /dev/null +++ b/src/components/PrettyForm.module.css @@ -0,0 +1,17 @@ +.form { + max-width: 500px; +} + +.form :is(button, input)[type="submit"] { + background: var(--bg-dark); + border: 0; + border-radius: 0.15rem; + cursor: pointer; + padding: 0.5em 1em; +} + +.error { + color: var(--error); + font-size: 80%; + transition: color var(--theme-transition); +} \ No newline at end of file diff --git a/src/components/PrettyForm.tsx b/src/components/PrettyForm.tsx new file mode 100644 index 0000000..b062d8e --- /dev/null +++ b/src/components/PrettyForm.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import styles from "./PrettyForm.module.css"; + +export default function PrettyForm({ + globalError, + children +}: { + globalError: string | null; + children: React.ReactNode; +}) { + return ( +
+ {globalError &&

{globalError}

} + {children} +
+ ); +} diff --git a/src/forms.ts b/src/forms.ts new file mode 100644 index 0000000..8554088 --- /dev/null +++ b/src/forms.ts @@ -0,0 +1,12 @@ +export async function fileAsBase64(f: File) { + const reader = new FileReader(); + reader.readAsArrayBuffer(f); + return new Promise((resolve, reject) => { + reader.onload = () => { + const result = reader.result as ArrayBuffer; + const buffer = Buffer.from(result); + resolve(buffer.toString("base64")); + }; + reader.onerror = () => reject(reader.error); + }); +} diff --git a/src/image.ts b/src/image.ts new file mode 100644 index 0000000..82026c9 --- /dev/null +++ b/src/image.ts @@ -0,0 +1,7 @@ +import sharp from "sharp"; + +export async function ensureJpg(avatar: Buffer) { + const img = await sharp(avatar).toFormat("jpeg").resize(512, 512); + const buf = await img.toBuffer(); + return buf.toString("base64"); +} diff --git a/src/ldap.ts b/src/ldap.ts index 8a4d187..d6f9a37 100644 --- a/src/ldap.ts +++ b/src/ldap.ts @@ -1,11 +1,11 @@ import { ApolloClient, InMemoryCache } from "@apollo/client"; import { Client } from "ldapts"; import { gql } from "./__generated__"; -import sharp from "sharp"; import { BerWriter } from "asn1"; import { User } from "@prisma/client"; +import { ensureJpg } from "@/image"; -type LLDAPAuthResponse = { +export type LLDAPAuthResponse = { token: string; refreshToken: string; }; @@ -21,31 +21,29 @@ export type UserInfo = { avatar?: string; }; -let ldapClient: Client | null = null; async function getLdapClient() { - if (ldapClient === null) { - ldapClient = new Client({ + if (global.ldapClient == null) { + global.ldapClient = new Client({ url: `ldap://${process.env.LDAP_HOST}:3890` }); const full = `uid=${process.env.LDAP_BIND_USER},ou=people,${process.env.LDAP_DC}`; - await ldapClient.bind(full, process.env.LDAP_BIND_PASSWORD); + await global.ldapClient.bind(full, process.env.LDAP_BIND_PASSWORD); } - return ldapClient; + return global.ldapClient; } -let authResponse: LLDAPAuthResponse | null = null; async function regenAuthToken() { - if (authResponse !== null) { + if (global.authResponse != null) { const url = `http://${process.env.LDAP_HOST}:17170/auth/refresh`; const req = await fetch(url, { headers: { - "Refresh-Token": authResponse.refreshToken + "Refresh-Token": global.authResponse.refreshToken } }); const res: LLDAPRefreshResponse = await req.json(); - authResponse.token = res.token; + global.authResponse.token = res.token; } else { const url = `http://${process.env.LDAP_HOST}:17170/auth/simple/login`; const req = await fetch(url, { @@ -59,7 +57,7 @@ async function regenAuthToken() { }) }); - authResponse = await req.json(); + global.authResponse = await req.json(); } // valid for one day, so refresh every 12 hours @@ -67,31 +65,35 @@ async function regenAuthToken() { } async function getAuthToken() { - if (authResponse === null) await regenAuthToken(); - return authResponse!.token; + if (global.authResponse == null) await regenAuthToken(); + return global.authResponse!.token; } -let graphQLClient: ApolloClient | null = null; -let graphQLCache = new InMemoryCache(); -let graphQLAuthToken: string | null = null; async function getGraphQLClient() { - if (authResponse === null) { + if (global.authResponse == null) { await getAuthToken(); - graphQLAuthToken = authResponse!.token; } - // We keep track of the auth token we used in the client, so we can - // recreate it when it expires/refreshes - if (graphQLClient === null || graphQLAuthToken !== authResponse!.token) { - graphQLClient = new ApolloClient({ - uri: `http://${process.env.LDAP_HOST}:17170/api/graphql`, - cache: graphQLCache, - headers: { - Authorization: `Bearer ${authResponse!.token}` + // Remake the client every time because Apollo caching is fucking stupid + let graphQLClient = new ApolloClient({ + uri: `http://${process.env.LDAP_HOST}:17170/api/graphql`, + cache: new InMemoryCache(), + defaultOptions: { + watchQuery: { + fetchPolicy: "no-cache", + errorPolicy: "ignore" + }, + query: { + fetchPolicy: "no-cache", + errorPolicy: "all" } - }); - } + }, + headers: { + Authorization: `Bearer ${global.authResponse!.token}` + } + }); + // whoever designed to cache this shit is FUCKING STUPID return graphQLClient; } @@ -110,12 +112,6 @@ export async function getUsers() { return query.data.users; } -async function ensureJpg(avatar: Buffer) { - const img = await sharp(avatar).toFormat("jpeg").resize(512, 512); - const buf = await img.toBuffer(); - return buf.toString("base64"); -} - export async function createUser( username: string, displayName: string, @@ -176,9 +172,8 @@ export async function validateUser(username: string, password: string) { } export async function checkUserExists(username: string) { - return (await getUsers()).find( - (u) => u.id.toLowerCase() === username.toLowerCase() - ); + const users = await getUsers(); + return users.find((u) => u.id.toLowerCase() === username.toLowerCase()); } export async function getUserInfo(user: User) { diff --git a/src/prisma.ts b/src/prisma.ts index 319b2f4..3ce0363 100644 --- a/src/prisma.ts +++ b/src/prisma.ts @@ -1,9 +1,8 @@ -import { PrismaClient } from "@prisma/client"; +import { Prisma, PrismaClient } from "@prisma/client"; import { DiscordAccessTokenResponse } from "./app/oauth/discord/oauth"; -const prisma = new PrismaClient(); -// refresh 6 hours before expiry -async function refreshDiscordTokens() { +async function refreshDiscordTokens(prisma: PrismaClient) { + // refresh 6 hours before expiry const refreshWindow = 6 * 60 * 60 * 1000; const discordAuths = await prisma.discordAuth.findMany({ @@ -43,7 +42,7 @@ async function refreshDiscordTokens() { } } -async function expireTickets() { +async function expireTickets(prisma: PrismaClient) { const expired = await prisma.authTicket.findMany({ where: { expiresAt: { @@ -61,9 +60,17 @@ async function expireTickets() { } } -setInterval(async () => { - await refreshDiscordTokens(); - await expireTickets(); -}, 60 * 1000); +let prisma: PrismaClient; +if (global.prisma == undefined) { + global.prisma = new PrismaClient(); + prisma = global.prisma; + + setInterval(async () => { + await refreshDiscordTokens(prisma); + await expireTickets(prisma); + }, 60 * 1000); +} else { + prisma = global.prisma; +} export default prisma; diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..0867682 --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,47 @@ +import * as Yup from "yup"; + +const REQUIRED = "Required."; +const USERNAME = Yup.string() + .required(REQUIRED) + .min(1, "Username is too short."); +const PASSWORD = Yup.string() + .required(REQUIRED) + .min(12, "Password must be at least 12 characters long."); + +export const loginSchema = Yup.object().shape({ + username: USERNAME, + password: PASSWORD +}); + +export type LoginFormValues = { + username: string; + password: string; +}; + +export const registerSchema: Yup.Schema = + Yup.object().shape({ + username: USERNAME, + displayName: Yup.string() + .required(REQUIRED) + .min(1, "Display name is too short."), + email: Yup.string().required(REQUIRED).email("Not an email."), + confirmPassword: Yup.string() + .required(REQUIRED) + .oneOf([Yup.ref("password", {})], "Passwords must match."), + password: PASSWORD, + avatar: Yup.mixed() + .test("fileSize", "File is larger than 1 MB.", (value) => { + if (value == null) return true; + return value.size <= 1_000_000; + }) + .optional() + }); + +export interface RegisterFormValues { + username: string; + displayName: string; + email: string; + password: string; + confirmPassword: string; + avatar?: File; +}