Compare commits

..

3 Commits

Author SHA1 Message Date
Skip R. 2acdeb8c95 chom 2023-04-26 19:59:17 -07:00
Skip R. c0b8ee2427 clean up avatar url allowed code 2023-04-26 18:22:56 -07:00
Skip R. f9f28810a2 glorbus 2023-04-26 18:21:28 -07:00
39 changed files with 1428 additions and 641 deletions

View File

@ -19,6 +19,9 @@ You will need:
- Ports are assumed to not have been changed from the defaults - Ports are assumed to not have been changed from the defaults
- A [Discord application](https://discord.com/developers/applications) for authentication - A [Discord application](https://discord.com/developers/applications) for authentication
- Set the redirect URL to `(your domain)/oauth/discord/redirect` - 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 ### 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 - `DISCORD_ALLOWED_GUILDS`: a comma separated list of guild IDs
- Users must be in one of these guilds to register with gluestick - Users must be in one of these guilds to register with gluestick
- Enable "Advanced > Developer Mode" in your Discord client to copy IDs - 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_HOST`: the IP address or hostname of your LLDAP server
- `LDAP_DC`: your LDAP dc - `LDAP_DC`: your LDAP dc
- `LDAP_BIND_USER`: the bind user of your LLDAP server - `LDAP_BIND_USER`: the bind user of your LLDAP server
@ -53,7 +61,12 @@ DISCORD_ALLOWED_GUILDS=986268106416611368,805978396974514206
LDAP_HOST=auth LDAP_HOST=auth
LDAP_DC=dc=n2,dc=pm LDAP_DC=dc=n2,dc=pm
LDAP_BIND_USER=admin 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/ BASE_DOMAIN=https://gluestick.n2.pm/
``` ```

14
environment.d.ts vendored
View File

@ -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 { declare global {
var prisma: PrismaClient | undefined;
var ldapClient: LDAPClient | undefined;
var authResponse: LLDAPAuthResponse | undefined;
namespace NodeJS { namespace NodeJS {
interface ProcessEnv { interface ProcessEnv {
DISCORD_CLIENT_ID: string; DISCORD_CLIENT_ID: string;
@ -10,6 +19,11 @@ declare global {
LDAP_BIND_USER: string; LDAP_BIND_USER: string;
LDAP_BIND_PASSWORD: string; LDAP_BIND_PASSWORD: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
GITHUB_TOKEN: string;
GITHUB_ORG: string;
BASE_DOMAIN: string; BASE_DOMAIN: string;
} }
} }

183
package-lock.json generated
View File

@ -14,9 +14,11 @@
"@types/react": "18.0.38", "@types/react": "18.0.38",
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
"asn1": "^0.2.6", "asn1": "^0.2.6",
"classnames": "^2.3.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "8.39.0", "eslint": "8.39.0",
"eslint-config-next": "13.3.1", "eslint-config-next": "13.3.1",
"formik": "^2.2.9",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"ldapts": "^4.2.5", "ldapts": "^4.2.5",
"next": "13.3.1", "next": "13.3.1",
@ -25,7 +27,8 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"sharp": "^0.32.0", "sharp": "^0.32.0",
"typescript": "5.0.4", "typescript": "5.0.4",
"uuid": "^9.0.0" "uuid": "^9.0.0",
"yup": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^3.3.1", "@graphql-codegen/cli": "^3.3.1",
@ -3136,6 +3139,11 @@
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
}, },
"node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"node_modules/clean-stack": { "node_modules/clean-stack": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@ -3464,6 +3472,14 @@
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" "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": { "node_modules/defaults": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@ -4425,6 +4441,34 @@
"is-callable": "^1.1.3" "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": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@ -5716,8 +5760,12 @@
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"dev": true },
"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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
@ -6757,6 +6805,11 @@
"react-is": "^16.13.1" "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": { "node_modules/pump": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@ -6861,6 +6914,11 @@
"react": "^18.2.0" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -7649,6 +7707,11 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true "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": { "node_modules/tiny-glob": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@ -7658,6 +7721,11 @@
"globrex": "^0.1.2" "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": { "node_modules/title-case": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
@ -7699,6 +7767,11 @@
"node": ">=8.0" "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": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@ -8186,6 +8259,28 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/zen-observable": {
"version": "0.8.15", "version": "0.8.15",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
@ -10466,6 +10561,11 @@
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
}, },
"classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"clean-stack": { "clean-stack": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@ -10717,6 +10817,11 @@
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" "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": { "defaults": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@ -11460,6 +11565,27 @@
"is-callable": "^1.1.3" "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": { "fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@ -12371,8 +12497,12 @@
"lodash": { "lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"dev": true },
"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": { "lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
@ -13100,6 +13230,11 @@
"react-is": "^16.13.1" "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": { "pump": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@ -13174,6 +13309,11 @@
"scheduler": "^0.23.0" "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": { "react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -13749,6 +13889,11 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true "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": { "tiny-glob": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@ -13758,6 +13903,11 @@
"globrex": "^0.1.2" "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": { "title-case": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
@ -13790,6 +13940,11 @@
"is-number": "^7.0.0" "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": { "tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@ -14138,6 +14293,24 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" "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": { "zen-observable": {
"version": "0.8.15", "version": "0.8.15",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",

View File

@ -18,9 +18,11 @@
"@types/react": "18.0.38", "@types/react": "18.0.38",
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
"asn1": "^0.2.6", "asn1": "^0.2.6",
"classnames": "^2.3.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "8.39.0", "eslint": "8.39.0",
"eslint-config-next": "13.3.1", "eslint-config-next": "13.3.1",
"formik": "^2.2.9",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"ldapts": "^4.2.5", "ldapts": "^4.2.5",
"next": "13.3.1", "next": "13.3.1",
@ -29,7 +31,8 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"sharp": "^0.32.0", "sharp": "^0.32.0",
"typescript": "5.0.4", "typescript": "5.0.4",
"uuid": "^9.0.0" "uuid": "^9.0.0",
"yup": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^3.3.1", "@graphql-codegen/cli": "^3.3.1",

View File

@ -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");

View File

@ -22,6 +22,7 @@ model User {
authTicket AuthTicket? authTicket AuthTicket?
discordAuth DiscordAuth? discordAuth DiscordAuth?
githubAuth GitHubAuth?
} }
model DiscordAuth { model DiscordAuth {
@ -34,3 +35,12 @@ model DiscordAuth {
refreshToken String refreshToken String
expiresAt DateTime expiresAt DateTime
} }
model GitHubAuth {
id Int @id
user User @relation(fields: [userId], references: [id])
userId Int @unique
accessToken String
}

View File

@ -2,6 +2,7 @@ import * as ldap from "@/ldap";
import prisma from "@/prisma"; import prisma from "@/prisma";
import { getUser } from "@/auth"; import { getUser } from "@/auth";
import { getDiscordAvatar } from "@/app/oauth/discord/oauth"; import { getDiscordAvatar } from "@/app/oauth/discord/oauth";
import { getGitHubAvatar } from "@/app/oauth/github/oauth";
import { getLogger } from "@/logger"; import { getLogger } from "@/logger";
type RequestBody = { 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) { if (password.length < 12) {
return new Response( return new Response(
JSON.stringify({ 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(); const users = await ldap.getUsers();
for (const user of users) { for (const user of users) {
if (user.id.toLowerCase() === username.toLowerCase()) { if (user.id.toLowerCase() === username.toLowerCase()) {

View File

@ -74,6 +74,11 @@ label {
color var(--theme-transition); color var(--theme-transition);
} }
input:disabled, button:disabled {
opacity: 0.5;
cursor: not-allowed !important;
}
input, input,
button { button {
font: inherit; font: inherit;

View File

@ -1,8 +1,5 @@
import ColorChanger from "@/components/ColorChanger"; import ColorChanger from "@/components/ColorChanger";
import "./globals.css"; import "./globals.css";
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export const metadata = { export const metadata = {
title: "gluestick", title: "gluestick",
@ -19,10 +16,11 @@ export default function RootLayout({
<html lang="en"> <html lang="en">
<head> <head>
<link rel="icon" href="/icon.svg" /> <link rel="icon" href="/icon.svg" />
{/* todo: lmfao */}
<meta property="og:image" content="/icon.svg" /> <meta property="og:image" content="/icon.svg" />
</head> </head>
<body className={inter.className}> <body>
{children} {children}
<ColorChanger /> <ColorChanger />

View File

@ -1,49 +1,77 @@
"use client"; "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"; import React from "react";
// TODO: use input from register & un programmer art this
export default function LoginForm() { export default function LoginForm() {
const usernameRef = React.useRef<HTMLInputElement>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const passwordRef = React.useRef<HTMLInputElement>(null);
async function handleFormSubmit(
{ username, password }: LoginFormValues,
{ setSubmitting }: FormikHelpers<LoginFormValues>
) {
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 ( return (
<form <PrettyForm globalError={globalError}>
style={{ display: "flex", flexDirection: "column" }} <Formik
onSubmit={async (e) => { initialValues={{ username: "", password: "" }}
e.preventDefault(); onSubmit={handleFormSubmit}
validationSchema={loginSchema}
const username = usernameRef.current?.value ?? ""; >
const password = passwordRef.current?.value ?? ""; {({ isSubmitting }) => (
<Form>
const req = await fetch("/api/login", { <Input
method: "POST", type="text"
headers: { placeholder="julian"
"Content-Type": "application/json" name="username"
}, label="Username"
body: JSON.stringify({ />
username, <Input
password type="password"
}) placeholder="deeznuts47"
}); name="password"
label="Password"
if (req.status === 200) { />
const res: { ticket: string } = await req.json(); <input type="submit" value="Login" disabled={isSubmitting} />
document.cookie = `ticket=${res.ticket}; path=/;`; </Form>
window.location.href = "/me"; )}
} else { </Formik>
// todo error handling lol </PrettyForm>
}
}}
>
<input type="text" placeholder="Username" ref={usernameRef} required />
<input
type="password"
placeholder="Password"
ref={passwordRef}
required
/>
<input type="submit" value="Login" />
</form>
); );
} }

View File

@ -1,103 +1,26 @@
.content { .content {
max-width: 500px; max-width: 700px;
margin: auto; margin: 0 auto;
} }
.header { .profileGrid {
padding: 1rem; /* todo */
padding-bottom: 0;
}
.form {
max-width: 500px;
}
.form input[type="submit"] {
padding: 1rem 1.5rem;
font-size: 140%;
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
cursor: pointer;
font-weight: 600;
}
.buttonContainer {
display: flex;
justify-content: center;
margin: 2rem 0;
}
.buttonContainer input:disabled {
cursor: not-allowed;
color: var(--fg-dark);
}
.formRow {
margin: 1rem 0;
display: flex;
flex-direction: row;
justify-content: center;
}
.formRow label {
font-variant: all-small-caps;
font-size: 105%;
width: 100px;
height: 50px;
/* center */
display: flex;
align-items: center;
}
.formVert {
flex-direction: column;
align-items: center;
}
.fancyInput {
padding: 0.5em 1em;
border: none;
border-radius: 0.15rem;
margin: 0.5rem 0;
width: 250px;
display: block;
background: var(--bg-dark);
}
.formRow input[name="avatar"] {
width: 190px;
}
.formRow .avatar {
margin-right: 10px;
border-radius: 10%;
}
.formRow input:disabled {
cursor: not-allowed;
background: var(--bg-darker);
color: var(--fg-darker);
}
.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);
} }
.divider { .divider {
width: 400px; width: 400px;
margin: auto;
background-color: var(--fg-darker); background-color: var(--fg-darker);
height: 1px; height: 1px;
border: none; border: none;
margin: 1rem auto;
}
.logout {
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
cursor: pointer;
padding: 0.5em 1em;
} }

View File

@ -4,8 +4,16 @@
import { UserInfo } from "@/ldap"; import { UserInfo } from "@/ldap";
import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react"; import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
import styles from "./AboutMe.module.css"; import styles from "./AboutMe.module.css";
import AvatarChanger from "@/components/AvatarChanger";
const fallbackAvatar = "https://i.clong.biz/i/oc4zjlqr.png"; import Input from "@/components/Input";
import { Form, Formik, FormikHelpers } from "formik";
import {
AboutMeFormValues,
PasswordUpdateFormValues,
aboutMeSchema,
passwordUpdateSchema
} from "@/schemas";
import PrettyForm from "@/components/PrettyForm";
type UpdateResponse = { type UpdateResponse = {
ok: boolean; ok: boolean;
@ -21,35 +29,6 @@ type InputProps = {
displayImage?: string; displayImage?: string;
} & InputHTMLAttributes<HTMLInputElement>; } & InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
// get console to shut up
const inputProps = { ...props };
delete inputProps.displayImage;
return (
<div className={styles.formRow}>
<label htmlFor={props.id}>{props.label}</label>
{props.displayImage && (
<img
src={props.displayImage}
className={styles.avatar}
alt={"Your avatar"}
width="50px"
height="50px"
/>
)}
<div className={styles.formVert}>
<input {...inputProps} ref={ref} className={styles.fancyInput} />
{props.error != null && <p className={styles.error}>{props.error}</p>}
</div>
</div>
);
});
Input.displayName = "Input";
async function fileAsBase64(f: File) { async function fileAsBase64(f: File) {
const reader = new FileReader(); const reader = new FileReader();
reader.readAsArrayBuffer(f); reader.readAsArrayBuffer(f);
@ -64,220 +43,200 @@ async function fileAsBase64(f: File) {
} }
export default function AboutMe({ info }: { info: UserInfo }) { export default function AboutMe({ info }: { info: UserInfo }) {
const displayNameRef = React.useRef<HTMLInputElement>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const emailRef = React.useRef<HTMLInputElement>(null); const initialValues: AboutMeFormValues = {
const avatarRef = React.useRef<HTMLInputElement>(null); username: info.username,
const submitRef = React.useRef<HTMLInputElement>(null); displayName: info.displayName,
email: info.email,
avatar: info.avatar
};
const [avatar, setAvatar] = React.useState<string | null>( async function handleFormSubmit(
info.avatar ?? null { displayName, email, avatar }: AboutMeFormValues,
); { setSubmitting }: FormikHelpers<AboutMeFormValues>
) {
setSubmitting(true);
const req = await fetch("/api/update", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
displayName,
email,
avatar: avatar != null ? avatar.split(",")[1] : null
})
});
setSubmitting(false);
const currentPasswordRef = React.useRef<HTMLInputElement>(null); try {
const newPasswordRef = React.useRef<HTMLInputElement>(null); const res: UpdateResponse = await req.json();
const confirmPasswordRef = React.useRef<HTMLInputElement>(null);
const submitPasswordRef = React.useRef<HTMLInputElement>(null);
const [incorrectPassword, setIncorrectPassword] = React.useState(false); if (!res.ok && res.error !== null) {
const [passwordMismatch, setPasswordMismatch] = React.useState(false); switch (res.error) {
const [avatarBig, setAvatarBig] = React.useState(false); case "avatarBig":
break;
}
}
} catch {
console.error(req);
}
}
const [passwordError, setPasswordError] = React.useState<string | null>(null);
const initialPasswordValues: PasswordUpdateFormValues = {
password: "",
newPassword: "",
confirmPassword: ""
};
async function handlePasswordSubmit(
{ password, newPassword }: PasswordUpdateFormValues,
{ setFieldError, setSubmitting }: FormikHelpers<PasswordUpdateFormValues>
) {
console.log(password, newPassword);
setSubmitting(true);
const req = await fetch("/api/changePassword", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
currentPassword: password,
newPassword: newPassword
})
});
setSubmitting(false);
try {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "incorrectPassword":
setFieldError("password", "Incorrect password.");
break;
}
}
} catch {
console.error(req);
}
}
return ( return (
<div className={styles.content}> <div className={styles.content}>
<h2 className={styles.header}>User information</h2> <h2 className={styles.userName}>{info.username}</h2>
<form <PrettyForm globalError={globalError}>
onSubmit={async (e) => { <Formik
e.preventDefault(); initialValues={initialValues}
onSubmit={handleFormSubmit}
validationSchema={aboutMeSchema}
>
{({ isSubmitting }) => (
<Form className={styles.profileGrid}>
<Input
type="text"
name="username"
label="Username"
defaultValue={info.username}
disabled
title="You can't change your username."
/>
<Input
type="text"
name="displayName"
label="Display name"
defaultValue={info.displayName}
/>
<Input
type="email"
name="email"
label="Email"
defaultValue={info.email}
/>
// turn the data uri into just base64 <Input
const avatarChanged = avatar !== null && avatar !== info.avatar; type="file"
const avatarData = avatarChanged ? avatar?.split(",")[1] : null; name="avatar"
label="Avatar"
accept="image/png, image/jpeg"
customRender={(fieldProps) => (
<AvatarChanger
currentAvatarBlob={fieldProps.field.value}
onChange={(newBlob) =>
fieldProps.form.setFieldValue("avatar", newBlob)
}
/>
)}
/>
submitRef.current!.disabled = true; <input
const req = await fetch("/api/update", { type="submit"
method: "POST", value="Save"
headers: { className={styles.fancyInput}
"Content-Type": "application/json" disabled={isSubmitting}
}, />
body: JSON.stringify({ </Form>
displayName: displayNameRef.current?.value, )}
email: emailRef.current?.value, </Formik>
avatar: avatarData </PrettyForm>
})
});
submitRef.current!.disabled = false;
try {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "avatarBig":
setAvatarBig(true);
break;
}
}
} catch {
console.error(req);
}
}}
>
<Input
type="text"
name="username"
label="Username"
defaultValue={info.username}
disabled
title="You can't change your username."
/>
<Input
type="text"
name="display-name"
label="Display name"
defaultValue={info.displayName}
ref={displayNameRef}
/>
<Input
type="email"
name="email"
label="Email"
defaultValue={info.email}
ref={emailRef}
/>
{/* why, html gods, why? */}
<input
type="file"
name="avatar"
accept="image/png, image/jpeg"
ref={avatarRef}
style={{ display: "none" }}
/>
<Input
type="button"
value="Choose file"
name="avatar"
label="Avatar"
accept="image/png, image/jpeg"
error={avatarBig ? "Avatar is too big." : undefined}
onClick={() => {
avatarRef.current?.click();
const eventListener = async () => {
avatarRef.current?.removeEventListener("change", eventListener);
const file = avatarRef.current?.files?.[0];
if (file == null) return;
if (file.size > 1_000_000) {
setAvatarBig(true);
return;
} else {
setAvatarBig(false);
}
const b64 = await fileAsBase64(file);
setAvatar(`data:${file.type};base64,${b64}`);
};
avatarRef.current?.addEventListener("change", eventListener);
}}
displayImage={avatar ?? fallbackAvatar}
/>
<div className={styles.formRow}>
<input
type="submit"
value="Save"
ref={submitRef}
className={styles.fancyInput}
/>
</div>
</form>
<hr className={styles.divider} /> <hr className={styles.divider} />
<h2 className={styles.header}>Change password</h2> <h2 className={styles.header}>Change password</h2>
<form <PrettyForm globalError={passwordError}>
onSubmit={async (e) => { <Formik
e.preventDefault(); initialValues={initialPasswordValues}
setIncorrectPassword(false); onSubmit={handlePasswordSubmit}
setPasswordMismatch(false); validationSchema={passwordUpdateSchema}
>
{({ isSubmitting }) => (
<Form className={styles.profileGrid}>
<Input
type="password"
name="password"
label="Current"
minLength={12}
required
/>
if ( <Input
newPasswordRef.current?.value !== confirmPasswordRef.current?.value type="password"
) { name="newPassword"
setPasswordMismatch(true); label="New"
return; minLength={12}
} required
/>
submitPasswordRef.current!.disabled = true; <Input
const req = await fetch("/api/changePassword", { type="password"
method: "POST", name="confirmPassword"
headers: { label="Confirm"
"Content-Type": "application/json" minLength={12}
}, required
body: JSON.stringify({ />
currentPassword: currentPasswordRef.current?.value,
newPassword: newPasswordRef.current?.value
})
});
submitPasswordRef.current!.disabled = false;
try { <input
const res: UpdateResponse = await req.json(); type="submit"
value="Save"
className={styles.fancyInput}
disabled={isSubmitting}
/>
</Form>
)}
</Formik>
</PrettyForm>
if (!res.ok && res.error !== null) { <hr className={styles.divider} />
switch (res.error) { <input
case "incorrectPassword": type="button"
setIncorrectPassword(true); value="Log out"
break; className={styles.logout}
} onClick={async () => {
} document.cookie =
} catch { "ticket=; expires=" + new Date().toUTCString() + "; path=/";
console.error(req); window.location.href = "/";
}
}} }}
> />
<Input
type="password"
name="current-password"
label="Current"
minLength={12}
required
ref={currentPasswordRef}
error={incorrectPassword ? "Incorrect password." : undefined}
/>
<Input
type="password"
name="new-password"
label="New"
minLength={12}
required
ref={newPasswordRef}
/>
<Input
type="password"
name="confirm-password"
label="Confirm"
ref={confirmPasswordRef}
minLength={12}
required
error={passwordMismatch ? "Passwords do not match." : undefined}
/>
<div className={styles.formRow}>
<input
type="submit"
value="Change password"
ref={submitPasswordRef}
className={styles.fancyInput}
/>
</div>
</form>
</div> </div>
); );
} }

View File

@ -8,7 +8,7 @@ export default async function Page() {
if (!user) redirect("/login"); if (!user) redirect("/login");
const info = await getUserInfo(user); const info = await getUserInfo(user);
if (info === null) redirect("/login"); if (info === null) redirect("/register");
return <AboutMe info={info} />; return <AboutMe info={info} />;
} }

View File

@ -11,6 +11,8 @@ export type DiscordAccessTokenResponse = {
export type DiscordUserResponse = { export type DiscordUserResponse = {
id: string; id: string;
avatar: string | null; avatar: string | null;
username: string;
email: string | null;
}; };
export type DiscordGuildResponse = { export type DiscordGuildResponse = {
@ -21,14 +23,14 @@ export function discordRedirectUri() {
return `${process.env.BASE_DOMAIN}oauth/discord/redirect`; 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", { const req = await fetch("https://discord.com/api/users/@me", {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}); });
const res: DiscordUserResponse = await req.json(); const res: DiscordUserResponse = await req.json();
return res.id; return res;
} }
export async function getDiscordGuilds(token: string) { export async function getDiscordGuilds(token: string) {
@ -51,8 +53,5 @@ export async function getDiscordAvatar(token: string) {
const res: DiscordUserResponse = await req.json(); const res: DiscordUserResponse = await req.json();
if (res.avatar === null) return null; if (res.avatar === null) return null;
const file = `https://cdn.discordapp.com/avatars/${res.id}/${res.avatar}.png`; const file = `https://cdn.discordapp.com/avatars/${res.id}/${res.avatar}.png`;
return file;
const avatarReq = await fetch(file);
const avatarBuffer = await avatarReq.arrayBuffer();
return Buffer.from(avatarBuffer);
} }

View File

@ -2,8 +2,9 @@ import { URLSearchParams } from "url";
import { import {
discordRedirectUri, discordRedirectUri,
DiscordAccessTokenResponse, DiscordAccessTokenResponse,
getDiscordID, getDiscordGuilds,
getDiscordGuilds getDiscordUser,
getDiscordAvatar
} from "../oauth"; } from "../oauth";
import { cookies } from "next/dist/client/components/headers"; import { cookies } from "next/dist/client/components/headers";
import prisma from "@/prisma"; import prisma from "@/prisma";
@ -54,14 +55,14 @@ export async function GET(request: Request) {
let tokenBody: DiscordAccessTokenResponse = await tokenResponse.json(); 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 guilds = await getDiscordGuilds(tokenBody.access_token);
const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? []; const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? [];
let allowed = false; let allowed = false;
for (const guild of allowedGuilds) if (guilds.includes(guild)) allowed = true; for (const guild of allowedGuilds) if (guilds.includes(guild)) allowed = true;
if (!allowed) { 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 }); 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({ const discordAuth = await prisma.discordAuth.upsert({
where: { where: {
id id: discordUser.id
}, },
create: { create: {
id, id: discordUser.id,
accessToken: tokenBody.access_token, accessToken: tokenBody.access_token,
refreshToken: tokenBody.refresh_token, refreshToken: tokenBody.refresh_token,
expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000), 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, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`, "Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
Location: "/register" Location: "/register?" + query.toString()
} }
}); });
} }

View File

@ -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=/;`
}
});
}

View File

@ -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;
}

View File

@ -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()
}
});
}

View File

@ -14,6 +14,7 @@ export default function Home() {
> >
<a href="/login">login</a> <a href="/login">login</a>
<a href="/oauth/discord/login">register (discord)</a> <a href="/oauth/discord/login">register (discord)</a>
<a href="/oauth/github/login">register (github)</a>
</p> </p>
</main> </main>
); );

View File

@ -2,7 +2,7 @@
max-width: 500px; max-width: 500px;
} }
.form input[type="submit"] { .form *[type="submit"] {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
font-size: 140%; font-size: 140%;
background: var(--bg-dark); background: var(--bg-dark);
@ -18,11 +18,6 @@
margin: 2rem 0; margin: 2rem 0;
} }
.buttonContainer input:disabled {
cursor: not-allowed;
color: var(--fg-dark);
}
.formRow { .formRow {
margin: 1rem 0; margin: 1rem 0;
} }

View File

@ -1,207 +1,167 @@
"use client"; "use client";
import React, { InputHTMLAttributes } from "react"; import React from "react";
import { HTMLInputTypeAttribute } from "react";
import styles from "./RegisterForm.module.css"; 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";
import AvatarChanger from "@/components/AvatarChanger";
type RegisterResponse = { type RegisterResponse = {
ok: boolean; ok: boolean;
error?: string; error?: string;
}; };
type InputProps = { export default function RegisterForm({
label: string; initialDisplayName,
name: string; initialEmail,
hint?: string; initialAvatarBase64
type: HTMLInputTypeAttribute; }: {
placeholder?: string; initialDisplayName?: string;
error?: string; initialEmail?: string;
} & InputHTMLAttributes<HTMLInputElement>; initialAvatarBase64?: string;
}) {
const [globalError, setGlobalError] = React.useState<string | null>(null);
const router = useRouter();
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => { const initialValues: RegisterFormValues = {
return ( username: "",
<div className={styles.formRow}> displayName: initialDisplayName ?? "",
<label htmlFor={props.id}>{props.label}</label> email: initialEmail ?? "",
<input {...props} ref={ref} /> password: "",
{props.error != null ? ( confirmPassword: "",
<p className={styles.error}>{props.error}</p> avatar: initialAvatarBase64
) : ( };
<p className={styles.hint}>{props.hint}</p>
)}
</div>
);
});
Input.displayName = "Input";
async function fileAsBase64(f: File) { async function handleFormSubmit(
const reader = new FileReader(); { avatar, username, displayName, email, password }: RegisterFormValues,
reader.readAsArrayBuffer(f); { setFieldError, setSubmitting }: FormikHelpers<RegisterFormValues>
return new Promise<string>((resolve, reject) => { ) {
reader.onload = () => { setSubmitting(true);
const result = reader.result as ArrayBuffer;
const buffer = Buffer.from(result);
resolve(buffer.toString("base64"));
};
reader.onerror = () => reject(reader.error);
});
}
export default function RegisterForm() { const resp = await fetch(`/api/register`, {
const usernameRef = React.useRef<HTMLInputElement>(null); method: "POST",
const displayNameRef = React.useRef<HTMLInputElement>(null); headers: {
const emailRef = React.useRef<HTMLInputElement>(null); "Content-Type": "application/json"
const passwordRef = React.useRef<HTMLInputElement>(null); },
const confirmPasswordRef = React.useRef<HTMLInputElement>(null); body: JSON.stringify({
const avatarRef = React.useRef<HTMLInputElement>(null); username,
const submitRef = React.useRef<HTMLInputElement>(null); displayName,
email,
password,
avatarBase64: avatar != null ? avatar.split(",")[1] : undefined
})
});
const [usernameTaken, setUsernameTaken] = React.useState(false); try {
const [passwordMismatch, setPasswordMismatch] = React.useState(false); const res: RegisterResponse = await resp.json();
const [avatarBig, setAvatarBig] = React.useState(false);
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 ( return (
<div className={styles.form}> <PrettyForm globalError={globalError}>
<form <Formik
onSubmit={async (e) => { initialValues={initialValues}
e.preventDefault(); onSubmit={handleFormSubmit}
validationSchema={registerSchema}
const [username, displayName, email, password, confirmPassword] = [ enableReinitialize
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);
}
}}
> >
<Input {({ isSubmitting }) => (
hint="The username you'll use to log into NotNet services. By standard, this should be lowercase, and usually your first name." <Form>
type="text" <Input
name="username" hint="The username you'll use to log into NotNet services. By standard, this should be lowercase, and usually your first name."
label="Username" type="text"
placeholder="julian" name="username"
ref={usernameRef} label="Username"
required placeholder="julian"
error={usernameTaken ? "Username is taken." : undefined} />
/>
<Input <Input
hint="Your display name - this can be what you go by online, for example." hint="Your display name - this can be what you go by online, for example."
type="text" type="text"
name="display-name" name="displayName"
label="Display name" label="Display name"
placeholder="NotNite" placeholder="NotNite"
ref={displayNameRef} />
required
/>
<Input <Input
hint="Your email address. An inbox will be created on @n2.pm that forwards to this email." hint="Your email address. An inbox will be created on @n2.pm that forwards to this email."
type="email" type="email"
name="email" name="email"
label="Email" label="Email"
placeholder="hi@notnite.com" placeholder="hi@notnite.com"
ref={emailRef} />
required
/>
<Input <Input
hint="Your password. To secure NotNet services, make this a strong and long password." hint="Your password. To secure NotNet services, make this a strong and long password."
type="password" type="password"
name="password" name="password"
label="Password" label="Password"
placeholder="deeznuts47" placeholder="deeznuts47"
minLength={12} minLength={12}
ref={passwordRef} autoComplete="new-password"
required />
autoComplete="new-password"
/>
<Input <Input
type="password" type="password"
name="confirm-password" name="confirmPassword"
label="Confirm password" label="Confirm password"
placeholder="deeznuts47" placeholder="deeznuts47"
minLength={12} minLength={12}
ref={confirmPasswordRef} />
required
error={passwordMismatch ? "Passwords do not match." : undefined}
/>
<Input <Input
hint={ hint={
"This image will automatically be used as your avatar with supported services - maximum 1 MB. " + "This image will automatically be used as your avatar with supported services - maximum 1 MB. "
"Will use the avatar of the service you signed up with if not provided." }
} type="file"
type="file" name="avatar"
name="avatar" label="Avatar"
label="Avatar" accept="image/png, image/jpeg"
accept="image/png, image/jpeg" customRender={(fieldProps) => (
ref={avatarRef} <AvatarChanger
error={avatarBig ? "Avatar is too big." : undefined} currentAvatarBlob={fieldProps.field.value}
/> onChange={(newBlob) =>
fieldProps.form.setFieldValue("avatar", newBlob)
}
/>
)}
/>
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
<input type="submit" value="Join NotNet!" ref={submitRef} /> <HugeSubmit value="Join NotNet!" disabled={isSubmitting} />
</div> </div>
</form> </Form>
</div> )}
</Formik>
</PrettyForm>
); );
} }

View File

@ -1,16 +1,63 @@
import { cookies } from "next/dist/client/components/headers"; import { cookies } from "next/dist/client/components/headers";
import styles from "@/app/page.module.css"; import styles from "@/app/page.module.css";
import RegisterForm from "./RegisterForm"; import RegisterForm from "./RegisterForm";
import { redirect } from "next/navigation"; import { redirect, useRouter } from "next/navigation";
import { ensureJpg } from "@/image";
export default function Page() { function avatarUrlAllowed(url: URL): boolean {
let github = url.hostname === "avatars.githubusercontent.com";
let discord = url.hostname === "cdn.discordapp.com";
if (discord && !url.pathname.startsWith("/avatars")) return false;
return github || discord;
}
export default async function Page({
searchParams
}: {
searchParams: {
displayName?: string;
email?: string;
avatar?: string;
};
}) {
const cookieStore = cookies(); const cookieStore = cookies();
const ticket = cookieStore.get("ticket"); const ticket = cookieStore.get("ticket");
if (ticket === null) redirect("/");
if (ticket === null) {
redirect("/");
}
let initialAvatarBase64 = undefined;
if (searchParams.avatar != null && searchParams.avatar !== "") {
const url = new URL(searchParams.avatar);
if (!avatarUrlAllowed(url)) {
return <p>fuck off</p>;
}
const req = await fetch(searchParams.avatar);
const blob = await req.blob();
const arrayBuffer = await blob.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.length <= 1_000_000) {
// I hope you are doing well, you deserve the best of luck while working on this project -Ari
try {
const jpg = await ensureJpg(buffer);
initialAvatarBase64 = "data:image/jpeg;base64," + jpg;
} catch (e) {
console.error(e);
}
}
}
return ( return (
<main className={styles.main}> <main className={styles.main}>
<RegisterForm /> <RegisterForm
initialDisplayName={searchParams.displayName}
initialEmail={searchParams.email}
initialAvatarBase64={initialAvatarBase64}
/>
</main> </main>
); );
} }

View File

@ -9,14 +9,14 @@ const logger = getLogger("auth.ts");
export async function getUser() { export async function getUser() {
const cookieStore = cookies(); const cookieStore = cookies();
const cookieTicket = cookieStore.get("ticket"); const cookieTicket = cookieStore.get("ticket");
if (cookieTicket === null) return null; if (cookieTicket == null) return null;
const ticket = await prisma.authTicket.findFirst({ const ticket = await prisma.authTicket.findFirst({
where: { where: {
ticket: cookieTicket?.value ticket: cookieTicket?.value
} }
}); });
if (ticket === null) return null; if (ticket == null) return null;
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {

View File

@ -0,0 +1,33 @@
.avatarChanger {
display: flex;
flex-flow: row nowrap;
gap: 1rem;
margin: 0.5rem 0;
}
.avatarChanger :is(img, svg) {
width: 3em;
height: 3em;
border-radius: 0.25rem;
}
.avatarChanger button svg {
width: 1.5em;
height: 1.5em;
margin-right: 0.5em;
}
.avatarChanger input[type=file] {
display: none;
}
.uploadButton {
display: flex;
align-items: center;
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
padding: 0.25em 1em;
cursor: pointer;
}

View File

@ -0,0 +1,53 @@
import React, { ChangeEvent } from "react";
import classnames from "classnames";
import styles from "./AvatarChanger.module.css";
import { fileAsBase64 } from "@/forms";
import UploadIcon from "./icons/UploadIcon";
import UserIcon from "./icons/UserIcon";
export default function AvatarChanger({
currentAvatarBlob,
onChange
}: {
currentAvatarBlob: string | null;
onChange: (newAvatar: string) => void;
}) {
const input = React.useRef<HTMLInputElement>(null);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.currentTarget.files?.[0];
if (file == null) return;
const base64 = await fileAsBase64(file);
onChange(`data:${file.type};base64,${base64}`);
}
// I give you the most support and well wishes while you work on this project -Ari
return (
<div className={classnames(styles.avatarChanger, "avatar-changer")}>
{currentAvatarBlob != null ? (
<img src={currentAvatarBlob!} alt="Your avatar" />
) : (
<UserIcon />
)}
<button
type="button"
className={styles.uploadButton}
onClick={() => {
input.current?.click();
}}
>
<UploadIcon />
Upload a new avatar
</button>
<input
type="file"
accept="image/png, image/jpeg"
ref={input}
onChange={handleFileChange}
/>
</div>
);
}

View File

@ -9,14 +9,14 @@ type ColorScheme = {
bg: string; bg: string;
bgDark: string; bgDark: string;
bgDarker?: string; bgDarker: string;
fg: string; fg: string;
fgDark: string; fgDark: string;
fgDarker?: string; fgDarker: string;
error?: string; error: string;
warning?: string; warning: string;
}; };
const colors: ColorScheme[] = [ const colors: ColorScheme[] = [
@ -153,8 +153,10 @@ function set(colorScheme: ColorScheme) {
const fixedColors = { const fixedColors = {
"--bg": colorScheme.bg, "--bg": colorScheme.bg,
"--bg-dark": colorScheme.bgDark, "--bg-dark": colorScheme.bgDark,
"--bg-darker": colorScheme.bgDarker,
"--fg": colorScheme.fg, "--fg": colorScheme.fg,
"--fg-dark": colorScheme.fgDark, "--fg-dark": colorScheme.fgDark,
"--fg-darker": colorScheme.fgDarker,
"--error": colorScheme.error ?? fallback.error!, "--error": colorScheme.error ?? fallback.error!,
"--warning": colorScheme.warning ?? fallback.warning! "--warning": colorScheme.warning ?? fallback.warning!
}; };

View File

@ -0,0 +1,10 @@
.hugeSubmit {
padding: 1rem 1.5rem;
font-size: 140%;
font-weight: 600;
}
.hugeSubmit:disabled {
cursor: not-allowed;
color: var(--fg-dark);
}

View File

@ -0,0 +1,8 @@
import React, { InputHTMLAttributes } from "react";
import styles from "./HugeSubmit.module.css";
export default function HugeSubmit(
props: InputHTMLAttributes<HTMLInputElement>
) {
return <input type="submit" className={styles.hugeSubmit} {...props} />;
}

View File

@ -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);
}

73
src/components/Input.tsx Normal file
View File

@ -0,0 +1,73 @@
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
import React from "react";
import styles from "./Input.module.css";
type CustomInputProps<T> = {
customRender?: (fieldProps: FieldProps) => React.ReactNode;
customOnChange?: (
event: React.ChangeEvent<HTMLInputElement>,
form: FormikProps<T>
) => void;
};
export default function Input<T>(
props: CustomInputProps<T> &
FieldAttributes<{ hint?: string; label: string; disabled?: boolean }>
) {
const generatedId = React.useId();
return (
<div className={styles.formRow}>
<label htmlFor={generatedId}>{props.label}</label>
<Field id={generatedId} {...props}>
{(fieldProps: FieldProps) => {
let { field, meta, form } = fieldProps;
let textAfterField =
meta.touched && meta.error ? (
<p className={styles.error}>{meta.error}</p>
) : (
props.hint && <p className={styles.hint}>{props.hint}</p>
);
// <input type="file"> 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 (
<>
{props.customRender == null ? (
<input
type={props.type}
placeholder={props.placeholder}
disabled={props.disabled}
title={props.title}
{...inputFields}
onChange={(event) => {
console.log(event);
if (props.customOnChange) {
console.log("using custom on change");
props.customOnChange(event, form);
} else {
form.setFieldValue(field.name, event.currentTarget.value);
}
}}
/>
) : (
props.customRender(fieldProps)
)}
{textAfterField}
</>
);
}}
</Field>
</div>
);
}

View File

@ -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);
}

View File

@ -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 (
<div className={styles.form}>
{globalError && <p className={styles.error}>{globalError}</p>}
{children}
</div>
);
}

View File

@ -0,0 +1,39 @@
import React from "react";
export default function UploadIcon() {
return (
<svg
viewBox="0 0 128 128"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
>
<path
d="M16,64 L16,92 C16,103.045695 24.954305,112 36,112 L92,112 C103.045695,112 112,103.045695 112,92 L112,64 L112,64"
stroke="currentColor"
strokeWidth="13"
></path>
<line
x1="64"
y1="80"
x2="64"
y2="16"
stroke="currentColor"
strokeWidth="13"
></line>
<polyline
stroke="currentColor"
strokeWidth="13"
points="32 48 64 16 96 48"
></polyline>
</g>
</svg>
);
}

View File

@ -0,0 +1,20 @@
import React from "react";
export default function UserIcon() {
return (
<svg
viewBox="0 0 128 128"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<circle fill="currentColor" cx="64" cy="48" r="32"></circle>
<path
d="M112,128 C112,101.490332 90.509668,80 64,80 C37.490332,80 16,101.490332 16,128 C16,128 112,128 112,128 Z"
fill="currentColor"
></path>
</g>
</svg>
);
}

12
src/forms.ts Normal file
View File

@ -0,0 +1,12 @@
export async function fileAsBase64(f: File) {
const reader = new FileReader();
reader.readAsArrayBuffer(f);
return new Promise<string>((resolve, reject) => {
reader.onload = () => {
const result = reader.result as ArrayBuffer;
const buffer = Buffer.from(result);
resolve(buffer.toString("base64"));
};
reader.onerror = () => reject(reader.error);
});
}

7
src/image.ts Normal file
View File

@ -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");
}

View File

@ -1,11 +1,11 @@
import { ApolloClient, InMemoryCache } from "@apollo/client"; import { ApolloClient, InMemoryCache } from "@apollo/client";
import { Client } from "ldapts"; import { Client } from "ldapts";
import { gql } from "./__generated__"; import { gql } from "./__generated__";
import sharp from "sharp";
import { BerWriter } from "asn1"; import { BerWriter } from "asn1";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { ensureJpg } from "@/image";
type LLDAPAuthResponse = { export type LLDAPAuthResponse = {
token: string; token: string;
refreshToken: string; refreshToken: string;
}; };
@ -21,31 +21,29 @@ export type UserInfo = {
avatar?: string; avatar?: string;
}; };
let ldapClient: Client | null = null;
async function getLdapClient() { async function getLdapClient() {
if (ldapClient === null) { if (global.ldapClient == null) {
ldapClient = new Client({ global.ldapClient = new Client({
url: `ldap://${process.env.LDAP_HOST}:3890` url: `ldap://${process.env.LDAP_HOST}:3890`
}); });
const full = `uid=${process.env.LDAP_BIND_USER},ou=people,${process.env.LDAP_DC}`; 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() { async function regenAuthToken() {
if (authResponse !== null) { if (global.authResponse != null) {
const url = `http://${process.env.LDAP_HOST}:17170/auth/refresh`; const url = `http://${process.env.LDAP_HOST}:17170/auth/refresh`;
const req = await fetch(url, { const req = await fetch(url, {
headers: { headers: {
"Refresh-Token": authResponse.refreshToken "Refresh-Token": global.authResponse.refreshToken
} }
}); });
const res: LLDAPRefreshResponse = await req.json(); const res: LLDAPRefreshResponse = await req.json();
authResponse.token = res.token; global.authResponse.token = res.token;
} else { } else {
const url = `http://${process.env.LDAP_HOST}:17170/auth/simple/login`; const url = `http://${process.env.LDAP_HOST}:17170/auth/simple/login`;
const req = await fetch(url, { 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 // valid for one day, so refresh every 12 hours
@ -67,31 +65,35 @@ async function regenAuthToken() {
} }
async function getAuthToken() { async function getAuthToken() {
if (authResponse === null) await regenAuthToken(); if (global.authResponse == null) await regenAuthToken();
return authResponse!.token; return global.authResponse!.token;
} }
let graphQLClient: ApolloClient<any> | null = null;
let graphQLCache = new InMemoryCache();
let graphQLAuthToken: string | null = null;
async function getGraphQLClient() { async function getGraphQLClient() {
if (authResponse === null) { if (global.authResponse == null) {
await getAuthToken(); await getAuthToken();
graphQLAuthToken = authResponse!.token;
} }
// We keep track of the auth token we used in the client, so we can // Remake the client every time because Apollo caching is fucking stupid
// recreate it when it expires/refreshes let graphQLClient = new ApolloClient({
if (graphQLClient === null || graphQLAuthToken !== authResponse!.token) { uri: `http://${process.env.LDAP_HOST}:17170/api/graphql`,
graphQLClient = new ApolloClient({ cache: new InMemoryCache(),
uri: `http://${process.env.LDAP_HOST}:17170/api/graphql`, defaultOptions: {
cache: graphQLCache, watchQuery: {
headers: { fetchPolicy: "no-cache",
Authorization: `Bearer ${authResponse!.token}` 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; return graphQLClient;
} }
@ -110,12 +112,6 @@ export async function getUsers() {
return query.data.users; 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( export async function createUser(
username: string, username: string,
displayName: string, displayName: string,
@ -176,9 +172,8 @@ export async function validateUser(username: string, password: string) {
} }
export async function checkUserExists(username: string) { export async function checkUserExists(username: string) {
return (await getUsers()).find( const users = await getUsers();
(u) => u.id.toLowerCase() === username.toLowerCase() return users.find((u) => u.id.toLowerCase() === username.toLowerCase());
);
} }
export async function getUserInfo(user: User) { export async function getUserInfo(user: User) {

View File

@ -1,9 +1,8 @@
import { PrismaClient } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import { DiscordAccessTokenResponse } from "./app/oauth/discord/oauth"; import { DiscordAccessTokenResponse } from "./app/oauth/discord/oauth";
const prisma = new PrismaClient();
// refresh 6 hours before expiry async function refreshDiscordTokens(prisma: PrismaClient) {
async function refreshDiscordTokens() { // refresh 6 hours before expiry
const refreshWindow = 6 * 60 * 60 * 1000; const refreshWindow = 6 * 60 * 60 * 1000;
const discordAuths = await prisma.discordAuth.findMany({ 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({ const expired = await prisma.authTicket.findMany({
where: { where: {
expiresAt: { expiresAt: {
@ -61,9 +60,17 @@ async function expireTickets() {
} }
} }
setInterval(async () => { let prisma: PrismaClient;
await refreshDiscordTokens(); if (global.prisma == undefined) {
await expireTickets(); global.prisma = new PrismaClient();
}, 60 * 1000); prisma = global.prisma;
setInterval(async () => {
await refreshDiscordTokens(prisma);
await expireTickets(prisma);
}, 60 * 1000);
} else {
prisma = global.prisma;
}
export default prisma; export default prisma;

89
src/schemas.ts Normal file
View File

@ -0,0 +1,89 @@
import * as Yup from "yup";
const REQUIRED = "Required.";
const USERNAME = Yup.string()
.required(REQUIRED)
.min(1, "Username is too short.");
const DISPLAY_NAME = Yup.string()
.required(REQUIRED)
.min(1, "Display name is too short.");
const EMAIL = Yup.string().required(REQUIRED).email("Not an email.");
const PASSWORD = Yup.string()
.required(REQUIRED)
.min(12, "Password must be at least 12 characters long.");
const CONFIRM_PASSWORD = (name: string) =>
Yup.string()
.required(REQUIRED)
.oneOf([Yup.ref(name, {})], "Passwords must match.");
const AVATAR = Yup.string().test(
"file-size",
"File is bigger than 1 MB.",
(value) => {
if (value == null) return true;
try {
const buf = Buffer.from(value, "base64");
return buf.length <= 1_000_000;
} catch (e) {
return false;
}
}
);
export const loginSchema = Yup.object().shape({
username: USERNAME,
password: PASSWORD
});
export type LoginFormValues = {
username: string;
password: string;
};
export const registerSchema: Yup.Schema<RegisterFormValues> =
Yup.object().shape({
username: USERNAME,
displayName: DISPLAY_NAME,
email: EMAIL,
password: PASSWORD,
confirmPassword: CONFIRM_PASSWORD("password"),
avatar: AVATAR
});
export interface RegisterFormValues {
username: string;
displayName: string;
email: string;
password: string;
confirmPassword: string;
avatar?: string;
}
export const aboutMeSchema: Yup.Schema<AboutMeFormValues> = Yup.object().shape({
username: USERNAME,
displayName: DISPLAY_NAME,
email: EMAIL,
avatar: AVATAR
});
export interface AboutMeFormValues {
username: string;
displayName: string;
email: string;
avatar?: string;
}
export const passwordUpdateSchema: Yup.Schema<PasswordUpdateFormValues> =
Yup.object().shape({
password: PASSWORD,
newPassword: PASSWORD,
confirmPassword: CONFIRM_PASSWORD("newPassword")
});
export interface PasswordUpdateFormValues {
password: string;
newPassword: string;
confirmPassword: string;
}