diff --git a/package.json b/package.json index 78a2270..0cd6770 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "itertools": "^2.3.2", "jose": "^5.6.3", "just-group-by": "^2.2.0", + "nodemailer": "^6.9.14", "pino": "^9.2.0", "postgres": "^3.4.4", "svelte": "^4.2.18", @@ -40,6 +41,7 @@ "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", "@types/node": "^20.14.10", + "@types/nodemailer": "^6.4.15", "@typescript-eslint/eslint-plugin": "^7.16.0", "@typescript-eslint/parser": "^7.16.0", "autoprefixer": "^10.4.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b05e05..ffe018f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: just-group-by: specifier: ^2.2.0 version: 2.2.0 + nodemailer: + specifier: ^6.9.14 + version: 6.9.14 pino: specifier: ^9.2.0 version: 9.2.0 @@ -72,6 +75,9 @@ importers: '@types/node': specifier: ^20.14.10 version: 20.14.10 + '@types/nodemailer': + specifier: ^6.4.15 + version: 6.4.15 '@typescript-eslint/eslint-plugin': specifier: ^7.16.0 version: 7.16.0(@typescript-eslint/parser@7.16.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3) @@ -149,8 +155,8 @@ packages: resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.24.8': - resolution: {integrity: sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==} + '@babel/runtime@7.24.7': + resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==} engines: {node: '>=6.9.0'} '@csstools/css-parser-algorithms@2.7.1': @@ -364,8 +370,8 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.4.15': + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -567,6 +573,9 @@ packages: '@types/node@20.14.10': resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==} + '@types/nodemailer@6.4.15': + resolution: {integrity: sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -787,8 +796,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.23.2: - resolution: {integrity: sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==} + browserslist@4.23.1: + resolution: {integrity: sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -821,8 +830,8 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001641: - resolution: {integrity: sha512-Phv5thgl67bHYo1TtMY/MurjkHhV4EDaCosezRXgZ8jzA/Ub+wjxAvbGvjoFENStinwi5kCyOYV3mi5tOGykwA==} + caniuse-lite@1.0.30001640: + resolution: {integrity: sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==} chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} @@ -1037,8 +1046,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.4.827: - resolution: {integrity: sha512-VY+J0e4SFcNfQy19MEoMdaIcZLmDCprqvBtkii1WTCTQHpRvf5N8+3kTYCgL/PcntvwQvmMJWTuDPsq+IlhWKQ==} + electron-to-chromium@1.4.819: + resolution: {integrity: sha512-8RwI6gKUokbHWcN3iRij/qpvf/wCbIVY5slODi85werwqUQwpFXM+dvUBND93Qh7SB0pW3Hlq3/wZsqQ3M9Jaw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1269,8 +1278,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + glob@10.4.4: + resolution: {integrity: sha512-XsOKvHsu38Xe19ZQupE6N/HENeHQBA05o3hV8labZZT2zYDg1+emxWHnc/Bm9AcCMPXfD6jt+QC7zC5JSFyumw==} + engines: {node: 14 >=14.21 || 16 >=16.20 || 18 || 20 || >=22} hasBin: true glob@7.2.3: @@ -1441,8 +1451,9 @@ packages: itertools@2.3.2: resolution: {integrity: sha512-urRg24zOOKt4qQHm3gzQLK5Mima/kMSP3DUfcVw05W3veUHxxqHPTEW08aKY9GIZm9CKvnULVqXdjZWNYalJHQ==} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@3.4.2: + resolution: {integrity: sha512-qH3nOSj8q/8+Eg8LUPOq3C+6HWkpUioIjDsq1+D4zY91oZvpPttw8GwtF1nReRYKXl+1AORyFqtm2f5Q1SB6/Q==} + engines: {node: 14 >=14.21 || 16 >=16.20 || >=18} jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} @@ -1548,8 +1559,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@10.4.2: + resolution: {integrity: sha512-voV4dDrdVZVNz84n39LFKDaRzfwhdzJ7akpyXfTMxCgRUp07U3lcJUXRlhTKP17rgt09sUzLi5iCitpEAr+6ug==} + engines: {node: 14 || 16 || 18 || 20 || >=22} lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} @@ -1684,6 +1696,10 @@ packages: node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + nodemailer@6.9.14: + resolution: {integrity: sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==} + engines: {node: '>=6.0.0'} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -1894,8 +1910,8 @@ packages: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} - postcss-selector-parser@6.1.1: - resolution: {integrity: sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==} + postcss-selector-parser@6.1.0: + resolution: {integrity: sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==} engines: {node: '>=4'} postcss-value-parser@4.2.0: @@ -2604,7 +2620,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.0.1 - '@babel/runtime@7.24.8': + '@babel/runtime@7.24.7': dependencies: regenerator-runtime: 0.14.1 @@ -2619,9 +2635,9 @@ snapshots: '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) '@csstools/css-tokenizer': 2.4.1 - '@csstools/selector-specificity@3.1.1(postcss-selector-parser@6.1.1)': + '@csstools/selector-specificity@3.1.1(postcss-selector-parser@6.1.0)': dependencies: - postcss-selector-parser: 6.1.1 + postcss-selector-parser: 6.1.0 '@dual-bundle/import-meta-resolve@4.1.0': {} @@ -2741,19 +2757,19 @@ snapshots: '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/trace-mapping': 0.3.25 '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': {} - '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.4.15': {} '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 '@linthtml/cli@0.9.5': dependencies: @@ -2969,6 +2985,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/nodemailer@6.4.15': + dependencies: + '@types/node': 20.14.10 + '@types/normalize-package-data@2.4.4': {} '@types/parse-json@4.0.2': {} @@ -3170,8 +3190,8 @@ snapshots: autoprefixer@10.4.19(postcss@8.4.39): dependencies: - browserslist: 4.23.2 - caniuse-lite: 1.0.30001641 + browserslist: 4.23.1 + caniuse-lite: 1.0.30001640 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.1 @@ -3213,12 +3233,12 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.23.2: + browserslist@4.23.1: dependencies: - caniuse-lite: 1.0.30001641 - electron-to-chromium: 1.4.827 + caniuse-lite: 1.0.30001640 + electron-to-chromium: 1.4.819 node-releases: 2.0.14 - update-browserslist-db: 1.1.0(browserslist@4.23.2) + update-browserslist-db: 1.1.0(browserslist@4.23.1) buffer-crc32@1.0.0: {} @@ -3248,7 +3268,7 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001641: {} + caniuse-lite@1.0.30001640: {} chalk@2.4.2: dependencies: @@ -3302,7 +3322,7 @@ snapshots: code-red@1.0.4: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 '@types/estree': 1.0.5 acorn: 8.12.1 estree-walker: 3.0.3 @@ -3380,7 +3400,7 @@ snapshots: date-fns@2.30.0: dependencies: - '@babel/runtime': 7.24.8 + '@babel/runtime': 7.24.7 date-fns@3.6.0: {} @@ -3451,7 +3471,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.4.827: {} + electron-to-chromium@1.4.819: {} emoji-regex@8.0.0: {} @@ -3515,7 +3535,7 @@ snapshots: eslint-plugin-svelte@2.42.0(eslint@8.57.0)(svelte@4.2.18): dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 eslint: 8.57.0 eslint-compat-utils: 0.5.1(eslint@8.57.0) esutils: 2.0.3 @@ -3523,7 +3543,7 @@ snapshots: postcss: 8.4.39 postcss-load-config: 3.1.4(postcss@8.4.39) postcss-safe-parser: 6.0.0(postcss@8.4.39) - postcss-selector-parser: 6.1.1 + postcss-selector-parser: 6.1.0 semver: 7.6.2 svelte-eslint-parser: 0.40.0(svelte@4.2.18) optionalDependencies: @@ -3724,10 +3744,10 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.4.5: + glob@10.4.4: dependencies: foreground-child: 3.2.1 - jackspeak: 3.4.3 + jackspeak: 3.4.2 minimatch: 9.0.5 minipass: 7.1.2 package-json-from-dist: 1.0.0 @@ -3893,7 +3913,7 @@ snapshots: itertools@2.3.2: {} - jackspeak@3.4.3: + jackspeak@3.4.2: dependencies: '@isaacs/cliui': 8.0.2 optionalDependencies: @@ -3978,7 +3998,7 @@ snapshots: chalk: 4.1.1 is-unicode-supported: 0.1.0 - lru-cache@10.4.3: {} + lru-cache@10.4.2: {} lru-cache@6.0.0: dependencies: @@ -3986,7 +4006,7 @@ snapshots: magic-string@0.30.10: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 make-dir@3.1.0: dependencies: @@ -4091,6 +4111,8 @@ snapshots: node-releases@2.0.14: {} + nodemailer@6.9.14: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -4207,7 +4229,7 @@ snapshots: path-scurry@1.11.1: dependencies: - lru-cache: 10.4.3 + lru-cache: 10.4.2 minipass: 7.1.2 path-type@4.0.0: {} @@ -4276,7 +4298,7 @@ snapshots: postcss-nested@6.0.1(postcss@8.4.39): dependencies: postcss: 8.4.39 - postcss-selector-parser: 6.1.1 + postcss-selector-parser: 6.1.0 postcss-resolve-nested-selector@0.1.1: {} @@ -4297,7 +4319,7 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-selector-parser@6.1.1: + postcss-selector-parser@6.1.0: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -4341,9 +4363,9 @@ snapshots: purgecss@6.0.0: dependencies: commander: 12.1.0 - glob: 10.4.5 + glob: 10.4.4 postcss: 8.4.39 - postcss-selector-parser: 6.1.1 + postcss-selector-parser: 6.1.0 queue-microtask@1.2.3: {} @@ -4517,7 +4539,7 @@ snapshots: sorcery@0.11.1: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 buffer-crc32: 1.0.0 minimist: 1.2.8 sander: 0.5.1 @@ -4593,7 +4615,7 @@ snapshots: '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) '@csstools/css-tokenizer': 2.4.1 '@csstools/media-query-list-parser': 2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) - '@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.1) + '@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.0) '@dual-bundle/import-meta-resolve': 4.1.0 balanced-match: 2.0.0 colord: 2.9.3 @@ -4620,7 +4642,7 @@ snapshots: postcss: 8.4.39 postcss-resolve-nested-selector: 0.1.1 postcss-safe-parser: 7.0.0(postcss@8.4.39) - postcss-selector-parser: 6.1.1 + postcss-selector-parser: 6.1.0 postcss-value-parser: 4.2.0 resolve-from: 5.0.0 string-width: 4.2.3 @@ -4637,7 +4659,7 @@ snapshots: dependencies: '@jridgewell/gen-mapping': 0.3.5 commander: 4.1.1 - glob: 10.4.5 + glob: 10.4.4 lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.6 @@ -4712,7 +4734,7 @@ snapshots: svelte@4.2.18: dependencies: '@ampproject/remapping': 2.3.0 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/trace-mapping': 0.3.25 '@types/estree': 1.0.5 acorn: 8.12.1 @@ -4764,7 +4786,7 @@ snapshots: postcss-js: 4.0.1(postcss@8.4.39) postcss-load-config: 4.0.2(postcss@8.4.39) postcss-nested: 6.0.1(postcss@8.4.39) - postcss-selector-parser: 6.1.1 + postcss-selector-parser: 6.1.0 resolve: 1.22.8 sucrase: 3.35.0 transitivePeerDependencies: @@ -4844,9 +4866,9 @@ snapshots: undici-types@5.26.5: {} - update-browserslist-db@1.1.0(browserslist@4.23.2): + update-browserslist-db@1.1.0(browserslist@4.23.1): dependencies: - browserslist: 4.23.2 + browserslist: 4.23.1 escalade: 3.1.2 picocolors: 1.0.1 diff --git a/postgres/init.sql b/postgres/init.sql index 0a321c3..59e597c 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -35,7 +35,8 @@ CREATE SCHEMA drap CREATE TABLE pendings ( session_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, expiration Expiration NOT NULL DEFAULT NOW() + INTERVAL '15 minutes', - nonce BYTEA NOT NULL DEFAULT gen_random_bytes(64) + nonce BYTEA NOT NULL DEFAULT gen_random_bytes(64), + has_extended_scope BOOLEAN NOT NULL ) CREATE TABLE sessions ( session_id UUID NOT NULL PRIMARY KEY, @@ -75,6 +76,15 @@ CREATE SCHEMA drap -- TODO: How do we enforce `student_email <> faculty_email`? FOREIGN KEY (draft_id, round, lab_id) REFERENCES faculty_choices (draft_id, round, lab_id), UNIQUE (draft_id, student_email) + ) + CREATE TABLE candidate_senders ( + email TEXT NOT NULL REFERENCES users (email) PRIMARY KEY, + access_token TEXT NOT NULL CONSTRAINT access_token_length CHECK (length(access_token) <= 2048), + refresh_token TEXT NOT NULL CONSTRAINT refresh_token_length CHECK (length(refresh_token) <= 512), + expiration Expiration NOT NULL + ) + CREATE TABLE designated_sender ( + email TEXT NOT NULL REFERENCES candidate_senders (email) ON DELETE CASCADE PRIMARY KEY ); INSERT INTO drap.labs (lab_id, lab_name) VALUES diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 3706ece..90369e6 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -6,6 +6,7 @@ import postgres from 'postgres'; import { FacultyChoice, FacultyChoiceEmail } from '$lib/models/faculty-choice'; import { Pending, Session } from '$lib/server/models/session'; +import { CandidateSender } from '$lib/server/models/email'; import { Draft } from '$lib/models/draft'; import { Lab } from '$lib/models/lab'; import { StudentRank } from '$lib/models/student-rank'; @@ -16,7 +17,7 @@ const BooleanResult = object({ result: boolean() }); const CountResult = object({ count: bigint() }); const CreatedLab = pick(Lab, ['lab_id']); const CreatedDraft = pick(Draft, ['draft_id', 'active_period_start']); -const DeletedPendingSession = pick(Pending, ['nonce', 'expiration']); +const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'has_extended_scope']); const DeletedValidSession = pick(Session, ['email', 'expiration']); const Drafts = array(Draft); const DraftEvents = array( @@ -50,6 +51,7 @@ const TaggedStudentsWithLabs = array( lab_id: nullable(FacultyChoiceEmail.entries.lab_id), }), ); +const UpsertedOpenIdUser = pick(User, ['is_admin', 'lab_id']); const UserEmails = array(pick(User, ['email'])); export type AvailableLabs = InferOutput; @@ -78,10 +80,10 @@ export class Database implements Loggable { return this.#sql.begin('ISOLATION LEVEL REPEATABLE READ', sql => fn(new Database(sql, this.#logger))); } - @timed async generatePendingSession() { + @timed async generatePendingSession(hasExtendedScope: boolean) { const sql = this.#sql; const [first, ...rest] = - await sql`INSERT INTO drap.pendings DEFAULT VALUES RETURNING session_id, expiration, nonce`; + await sql`INSERT INTO drap.pendings (has_extended_scope) VALUES (${hasExtendedScope}) RETURNING session_id, expiration, nonce, has_extended_scope`; strictEqual(rest.length, 0); return parse(Pending, first); } @@ -89,7 +91,7 @@ export class Database implements Loggable { @timed async deletePendingSession(sid: Pending['session_id']) { const sql = this.#sql; const [first, ...rest] = - await sql`DELETE FROM drap.pendings WHERE session_id = ${sid} RETURNING expiration, nonce`; + await sql`DELETE FROM drap.pendings WHERE session_id = ${sid} RETURNING expiration, nonce, has_extended_scope`; strictEqual(rest.length, 0); return typeof first === 'undefined' ? null : parse(DeletedPendingSession, first); } @@ -136,9 +138,10 @@ export class Database implements Loggable { avatar: User['avatar'], ) { const sql = this.#sql; - const { count } = - await sql`INSERT INTO drap.users AS u (email, user_id, given_name, family_name, avatar) VALUES (${email}, ${uid}, ${given}, ${family}, ${avatar}) ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE SET user_id = EXCLUDED.user_id, given_name = coalesce(nullif(trim(u.given_name), ''), EXCLUDED.given_name), family_name = coalesce(nullif(trim(u.family_name), ''), EXCLUDED.family_name), avatar = EXCLUDED.avatar`; - return count; + const [first, ...rest] = + await sql`INSERT INTO drap.users AS u (email, user_id, given_name, family_name, avatar) VALUES (${email}, ${uid}, ${given}, ${family}, ${avatar}) ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE SET user_id = EXCLUDED.user_id, given_name = coalesce(nullif(trim(u.given_name), ''), EXCLUDED.given_name), family_name = coalesce(nullif(trim(u.family_name), ''), EXCLUDED.family_name), avatar = EXCLUDED.avatar RETURNING is_admin, lab_id`; + strictEqual(rest.length, 0); + return parse(UpsertedOpenIdUser, first); } @timed async updateProfileBySession( @@ -367,6 +370,49 @@ export class Database implements Loggable { return typeof first === 'undefined' ? null : parse(QueriedStudentRank, first); } + /** + * A designated sender is an admin (i.e., `is_admin=True` and `lab_id=NULL`) with valid + * OAuth 2.0 credentials * such that the access token will not expire in the next five minutes. + */ + @timed async getDesignatedSenderCredentials() { + const sql = this.#sql; + const [first, ...rest] = + await sql`SELECT email, access_token, refresh_token, expiration FROM drap.designated_sender JOIN drap.candidate_senders USING (email) JOIN drap.users (email) WHERE user_id IS NOT NULL AND is_admin AND lab_id IS NULL`; + strictEqual(rest.length, 0); + return typeof first === 'undefined' ? null : parse(CandidateSender, first); + } + + @timed async upsertCandidateSender( + email: CandidateSender['email'], + expiration: CandidateSender['expiration'], + accessToken: CandidateSender['access_token'], + refreshToken?: CandidateSender['refresh_token'], + ) { + const sql = this.#sql; + const query = + typeof refreshToken === 'undefined' + ? sql`INSERT INTO drap.candidate_senders (email, expiration, access_token) VALUES (${email}, ${expiration}, ${accessToken}) ON CONFLICT ON CONSTRAINT candidate_senders_pkey DO UPDATE SET expiration = EXCLUDED.expiration, access_token = EXCLUDED.access_token` + : sql`INSERT INTO drap.candidate_senders (email, expiration, access_token, refresh_token) VALUES (${email}, ${expiration}, ${accessToken}, ${refreshToken}) ON CONFLICT ON CONSTRAINT candidate_senders_pkey DO UPDATE SET expiration = EXCLUDED.expiration, access_token = EXCLUDED.access_token, refresh_token = EXCLUDED.refresh_token`; + const { count } = await query; + strictEqual(count, 1); + } + + @timed async clearDesignatedSenders() { + const sql = this.#sql; + await sql`TRUNCATE drap.designated_sender`; + } + + @timed async insertDesignatedSender(email: CandidateSender['email']) { + const sql = this.#sql; + const { count } = await sql`INSERT INTO drap.designated_sender (email) VALUES (${email})`; + strictEqual(count, 1); + } + + /** + * The operation of inserting a faculty choice must necessarily occur + * with the updating of a student_rank entry's chosen_by field; note + * the two return values for this function. + */ @timed async insertFacultyChoice( draft: StudentRank['draft_id'], lab: FacultyChoice['lab_id'], diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts new file mode 100644 index 0000000..c27ad1d --- /dev/null +++ b/src/lib/server/email.ts @@ -0,0 +1,58 @@ +import { IdToken, TokenResponse } from '$lib/server/models/oauth'; +import { isPast, sub } from 'date-fns'; +import { parse, pick } from 'valibot'; +import { createTransport } from 'nodemailer'; +import { fetchJwks } from '$lib/server/jwks'; +import { jwtVerify } from 'jose'; + +import type { Database } from '$lib/server/database'; +import GOOGLE from '$lib/server/env/google'; + +async function refreshAccessToken(db: Database, email: string, refreshToken: string) { + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + refresh_token: refreshToken, + client_id: GOOGLE.OAUTH_CLIENT_ID, + client_secret: GOOGLE.OAUTH_CLIENT_SECRET, + grant_type: 'refresh_token', + }), + }); + + const json = await res.json(); + const { id_token, access_token } = parse(TokenResponse, json); + const { payload } = await jwtVerify(id_token, fetchJwks, { + issuer: 'https://accounts.google.com', + audience: GOOGLE.OAUTH_CLIENT_ID, + }); + + const token = parse(pick(IdToken, ['exp']), payload); + await db.upsertCandidateSender(email, token.exp, access_token); + return access_token; +} + +// this function sends an email to the provided email address with the given body via nodemailer using the access token of the designated admin sender +export async function sendEmailTo(db: Database, to: string, subject: string, text: string) { + const credentials = await db.getDesignatedSenderCredentials(); + if (credentials === null) return false; + + const sendMail = await createTransport({ + host: 'smtp.gmail.com', + port: 465, + secure: true, + auth: { + type: 'OAuth2', + user: credentials.email, + clientId: GOOGLE.OAUTH_CLIENT_ID, + clientSecret: GOOGLE.OAUTH_CLIENT_SECRET, + refreshToken: credentials.refresh_token, + accessToken: isPast(sub(credentials.expiration, { minutes: 10 })) + ? await refreshAccessToken(db, credentials.email, credentials.refresh_token) + : credentials.access_token, + }, + }).sendMail({ from: credentials.email, to, subject, text }); + + db.logger.info({ sendMail }); + return true; +} diff --git a/src/lib/server/jwks.js b/src/lib/server/jwks.js new file mode 100644 index 0000000..60050f8 --- /dev/null +++ b/src/lib/server/jwks.js @@ -0,0 +1,2 @@ +import { createRemoteJWKSet } from 'jose'; +export const fetchJwks = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs')); diff --git a/src/lib/server/models/email.ts b/src/lib/server/models/email.ts new file mode 100644 index 0000000..58896a9 --- /dev/null +++ b/src/lib/server/models/email.ts @@ -0,0 +1,11 @@ +import { type InferOutput, date, maxLength, object, pipe, string } from 'valibot'; +import { User } from '$lib/models/user'; + +export const CandidateSender = object({ + expiration: date(), + email: User.entries.email, + access_token: pipe(string(), maxLength(2048)), + refresh_token: pipe(string(), maxLength(512)), +}); + +export type CandidateSender = InferOutput; diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index c15f30a..4ce02c1 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -7,6 +7,7 @@ import { minLength, number, object, + optional, pipe, safeInteger, string, @@ -19,8 +20,12 @@ const OAUTH_SCOPES = [ 'openid', 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/userinfo.email', + 'https://mail.google.com/', ]; +const SENDER_OAUTH_SCOPES = OAUTH_SCOPES.concat('https://mail.google.com/'); + export const OAUTH_SCOPE_STRING = OAUTH_SCOPES.join(' '); +export const SENDER_SCOPE_STRING = SENDER_OAUTH_SCOPES.join(' '); export const OAUTH_TOKEN_TYPE = 'Bearer'; /** @see https://developers.google.com/identity/protocols/oauth2#size */ @@ -29,15 +34,18 @@ export const AuthorizationCode = pipe(string(), minLength(1), maxLength(256)); export const TokenResponse = object({ // JSON Web Token token containing the user's ID token. id_token: string(), + access_token: string(), // Always set to `OAUTH_SCOPE` for now. scope: pipe( string(), transform(str => str.split(' ')), - everyItem(item => OAUTH_SCOPES.includes(item)), + everyItem(str => SENDER_OAUTH_SCOPES.includes(str)), ), token_type: literal(OAUTH_TOKEN_TYPE), // Remaining lifetime in seconds. expires_in: pipe(number(), safeInteger()), + // Refresh token, will not always be given with every TokenResponse (requires prompt=consent&access_type=offline) + refresh_token: optional(string()), }); const UnixTimeSecs = pipe( diff --git a/src/lib/server/models/session.ts b/src/lib/server/models/session.ts index 42e4953..87a0d33 100644 --- a/src/lib/server/models/session.ts +++ b/src/lib/server/models/session.ts @@ -1,4 +1,4 @@ -import { type InferOutput, date, instance, object, pipe, string, uuid } from 'valibot'; +import { type InferOutput, boolean, date, instance, object, pipe, string, uuid } from 'valibot'; import { User } from '$lib/models/user'; const CommonSchema = object({ @@ -6,7 +6,11 @@ const CommonSchema = object({ expiration: date(), }); -export const Pending = object({ ...CommonSchema.entries, nonce: instance(Uint8Array) }); +export const Pending = object({ + ...CommonSchema.entries, + nonce: instance(Uint8Array), + has_extended_scope: boolean(), +}); export type Pending = InferOutput; export const Session = object({ ...CommonSchema.entries, email: User.entries.email }); diff --git a/src/routes/oauth/callback/+server.js b/src/routes/oauth/callback/+server.js index 9a2a923..149684e 100644 --- a/src/routes/oauth/callback/+server.js +++ b/src/routes/oauth/callback/+server.js @@ -1,13 +1,12 @@ import { AuthorizationCode, IdToken, TokenResponse } from '$lib/server/models/oauth'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; import { error, redirect } from '@sveltejs/kit'; import { ok, strictEqual } from 'node:assert/strict'; import { Buffer } from 'node:buffer'; import GOOGLE from '$lib/server/env/google'; +import { fetchJwks } from '$lib/server/jwks'; +import { jwtVerify } from 'jose'; import { parse } from 'valibot'; -const fetchJwks = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs')); - export async function GET({ fetch, locals: { db }, cookies, url: { searchParams } }) { const sid = cookies.get('sid'); if (typeof sid === 'undefined') redirect(302, '/oauth/login/'); @@ -43,7 +42,7 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams ok(res.ok); const json = await res.json(); - const { id_token } = parse(TokenResponse, json); + const { id_token, access_token, refresh_token } = parse(TokenResponse, json); const { payload } = await jwtVerify(id_token, fetchJwks, { issuer: 'https://accounts.google.com', audience: GOOGLE.OAUTH_CLIENT_ID, @@ -55,8 +54,21 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams // Insert user as uninitialized by default await db.initUser(token.email); - await db.upsertOpenIdUser(token.email, token.sub, token.given_name, token.family_name, token.picture); + const { is_admin, lab_id } = await db.upsertOpenIdUser( + token.email, + token.sub, + token.given_name, + token.family_name, + token.picture, + ); await db.insertValidSession(sid, token.email, token.exp); + + if (pending.has_extended_scope && typeof refresh_token !== 'undefined' && is_admin && lab_id === null) { + await db.upsertCandidateSender(token.email, token.exp, access_token, refresh_token); + await db.clearDesignatedSenders(); + await db.insertDesignatedSender(token.email); + } + return token.exp; }); diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index 6fc9e24..8a12132 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -1,18 +1,20 @@ +import { OAUTH_SCOPE_STRING, SENDER_SCOPE_STRING } from '$lib/server/models/oauth'; import { Buffer } from 'node:buffer'; import GOOGLE from '$lib/server/env/google'; -import { OAUTH_SCOPE_STRING } from '$lib/server/models/oauth'; import { redirect } from '@sveltejs/kit'; -export async function GET({ locals: { db }, cookies }) { +export async function GET({ locals: { db }, cookies, url: { searchParams } }) { const sid = cookies.get('sid'); + const hasExtendedScope = searchParams.has('extended'); if (typeof sid !== 'undefined') { const user = await db.getUserFromValidSession(sid); if (user !== null) redirect(302, '/'); } - const { session_id, nonce, expiration } = await db.generatePendingSession(); + const { session_id, nonce, expiration } = await db.generatePendingSession(hasExtendedScope); cookies.set('sid', session_id, { path: '/', httpOnly: true, sameSite: 'lax', expires: expiration }); + // TODO: Use more secure CSRF token. Hash the entire session details instead of the public session ID. const hashedSessionId = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(session_id)); const params = new URLSearchParams({ state: Buffer.from(hashedSessionId).toString('base64url'), @@ -20,10 +22,17 @@ export async function GET({ locals: { db }, cookies }) { redirect_uri: GOOGLE.OAUTH_REDIRECT_URI, nonce: Buffer.from(nonce).toString('base64url'), hd: 'up.edu.ph', - access_type: 'online', response_type: 'code', - scope: OAUTH_SCOPE_STRING, }); + if (hasExtendedScope) { + params.set('access_type', 'offline'); + params.set('scope', SENDER_SCOPE_STRING); + params.set('prompt', 'consent'); + } else { + params.set('access_type', 'online'); + params.set('scope', OAUTH_SCOPE_STRING); + } + redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params}`); }