From dacb520a981276ea19d0a26fe03f2cc5ac7bb13a Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 13:41:11 +0800 Subject: [PATCH 01/68] feat: add refresh and access token to TokenResponse model --- src/lib/server/models/oauth.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index c15f30a..2fe5168 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -5,6 +5,7 @@ import { literal, maxLength, minLength, + nullable, number, object, pipe, @@ -29,6 +30,7 @@ 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(), @@ -38,6 +40,8 @@ export const TokenResponse = object({ 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: nullable(string()) }); const UnixTimeSecs = pipe( From 8c4a4b0e645457e92d1f71e6e133de3ab3082747 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 14:00:13 +0800 Subject: [PATCH 02/68] fix: make refresh token optional instead of nullable --- src/lib/server/models/oauth.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index 2fe5168..972f847 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -5,9 +5,9 @@ import { literal, maxLength, minLength, - nullable, number, object, + optional, pipe, safeInteger, string, @@ -41,7 +41,7 @@ export const TokenResponse = object({ // 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: nullable(string()) + refresh_token: optional(string()) }); const UnixTimeSecs = pipe( From dbb533264e6d3da1b7b4bd80c26538538dea27b8 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 14:02:35 +0800 Subject: [PATCH 03/68] feat(db): add tokens to users schema --- postgres/init.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/postgres/init.sql b/postgres/init.sql index 0a321c3..c18b2bc 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -31,6 +31,8 @@ CREATE SCHEMA drap given_name TEXT NOT NULL DEFAULT '', family_name TEXT NOT NULL DEFAULT '', avatar TEXT NOT NULL DEFAULT '' + access_token TEXT NOT NULL + refresh_token TEXT ) CREATE TABLE pendings ( session_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, From 8cc8a8f40acfd818f9b1106b071a2c0bf4a85ec2 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 14:25:43 +0800 Subject: [PATCH 04/68] feat: reimplement user upsert --- src/lib/server/database.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 3706ece..17c313d 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -9,6 +9,7 @@ import { Pending, Session } from '$lib/server/models/session'; import { Draft } from '$lib/models/draft'; import { Lab } from '$lib/models/lab'; import { StudentRank } from '$lib/models/student-rank'; +import { TokenResponse } from '$lib/server/models/oauth'; import { User } from '$lib/models/user'; const AvailableLabs = array(pick(Lab, ['lab_id', 'lab_name'])); @@ -134,10 +135,12 @@ export class Database implements Loggable { given: User['given_name'], family: User['family_name'], avatar: User['avatar'], + access_token: string, + refresh_token: string | undefined ) { 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`; + await sql`INSERT INTO drap.users AS u (email, user_id, given_name, family_name, avatar, access_token, refresh_token) VALUES (${email}, ${uid}, ${given}, ${family}, ${avatar}, ${access_token}, ${refresh_token ?? 'NULL'}) ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE SET user_id = ${uid}, given_name = coalesce(nullif(trim(u.given_name), ''), ${given}), family_name = coalesce(nullif(trim(u.family_name), ''), ${family}), avatar = ${avatar}`; return count; } From 8a66cd6cb7917889992d9b643269d995a82235d5 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 14:57:08 +0800 Subject: [PATCH 05/68] fix: add access_token to initUser --- src/lib/server/database.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 17c313d..33104a9 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -9,7 +9,6 @@ import { Pending, Session } from '$lib/server/models/session'; import { Draft } from '$lib/models/draft'; import { Lab } from '$lib/models/lab'; import { StudentRank } from '$lib/models/student-rank'; -import { TokenResponse } from '$lib/server/models/oauth'; import { User } from '$lib/models/user'; const AvailableLabs = array(pick(Lab, ['lab_id', 'lab_name'])); @@ -122,10 +121,10 @@ export class Database implements Loggable { return typeof first === 'undefined' ? null : parse(DeletedValidSession, first); } - @timed async initUser(email: User['email']) { + @timed async initUser(email: User['email'], access_token: string) { const sql = this.#sql; const { count } = - await sql`INSERT INTO drap.users (email) VALUES (${email}) ON CONFLICT ON CONSTRAINT users_pkey DO NOTHING RETURNING student_number, lab_id`; + await sql`INSERT INTO drap.users (email, access_token) VALUES (${email}, ${access_token}) ON CONFLICT ON CONSTRAINT users_pkey DO NOTHING RETURNING student_number, lab_id`; return count; } From 735df8f980b5142b36d55f3b24732b5274103136 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 14:57:23 +0800 Subject: [PATCH 06/68] feat: use all new user db methods --- src/routes/oauth/callback/+server.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/oauth/callback/+server.js b/src/routes/oauth/callback/+server.js index 9a2a923..73aa92e 100644 --- a/src/routes/oauth/callback/+server.js +++ b/src/routes/oauth/callback/+server.js @@ -43,7 +43,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, @@ -54,12 +54,12 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams strictEqual(Buffer.from(token.nonce, 'base64url').compare(pending.nonce), 0); // 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); + await db.initUser(token.email, access_token); + await db.upsertOpenIdUser(token.email, token.sub, token.given_name, token.family_name, token.picture, access_token, refresh_token); await db.insertValidSession(sid, token.email, token.exp); return token.exp; }); cookies.set('sid', sid, { path: '/', httpOnly: true, sameSite: 'lax', expires }); - redirect(302, '/'); + redirect(302, '/dashboard/'); } From 3e5f4a36654407f7a404164d7a09d5eae563f5dd Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 15:00:55 +0800 Subject: [PATCH 07/68] feat(db): add designated_sender table to schema --- postgres/init.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/postgres/init.sql b/postgres/init.sql index c18b2bc..e514ee1 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -77,6 +77,8 @@ 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 designated_sender ( + email REFERENCES user.email, ); INSERT INTO drap.labs (lab_id, lab_name) VALUES From aee732b43eea1c3adb0b6764bce3d66603933a99 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 15:16:29 +0800 Subject: [PATCH 08/68] feat: install nodemailer --- package.json | 1 + pnpm-lock.yaml | 120 +++++++++++++++++++++++++++---------------------- 2 files changed, 67 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 78a2270..a220a7d 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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b05e05..7fbd6e4 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 @@ -149,8 +152,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 +367,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==} @@ -787,8 +790,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 +824,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 +1040,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 +1272,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 +1445,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 +1553,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 +1690,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 +1904,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 +2614,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 +2629,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 +2751,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: @@ -3170,8 +3180,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 +3223,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 +3258,7 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001641: {} + caniuse-lite@1.0.30001640: {} chalk@2.4.2: dependencies: @@ -3302,7 +3312,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 +3390,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 +3461,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 +3525,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 +3533,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 +3734,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 +3903,7 @@ snapshots: itertools@2.3.2: {} - jackspeak@3.4.3: + jackspeak@3.4.2: dependencies: '@isaacs/cliui': 8.0.2 optionalDependencies: @@ -3978,7 +3988,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 +3996,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 +4101,8 @@ snapshots: node-releases@2.0.14: {} + nodemailer@6.9.14: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -4207,7 +4219,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 +4288,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 +4309,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 +4353,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 +4529,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 +4605,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 +4632,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 +4649,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 +4724,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 +4776,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 +4856,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 From 66a3a69d625e3739a10df2a6995475e9332dfa23 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 15:17:03 +0800 Subject: [PATCH 09/68] feat(db): add singleton schema for emailer --- postgres/init.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/postgres/init.sql b/postgres/init.sql index e514ee1..8d64965 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -78,7 +78,7 @@ CREATE SCHEMA drap FOREIGN KEY (draft_id, round, lab_id) REFERENCES faculty_choices (draft_id, round, lab_id), UNIQUE (draft_id, student_email) CREATE TABLE designated_sender ( - email REFERENCES user.email, + email REFERENCES user.email ); INSERT INTO drap.labs (lab_id, lab_name) VALUES From 9c26ca204de3c6e97d93405ba843a74449a718fb Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 15:29:47 +0800 Subject: [PATCH 10/68] fix: fix schema declarations --- postgres/init.sql | 6 +++--- src/lib/server/database.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index 8d64965..ff6ac24 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -30,8 +30,8 @@ CREATE SCHEMA drap email TEXT NOT NULL PRIMARY KEY, given_name TEXT NOT NULL DEFAULT '', family_name TEXT NOT NULL DEFAULT '', - avatar TEXT NOT NULL DEFAULT '' - access_token TEXT NOT NULL + avatar TEXT NOT NULL DEFAULT '', + access_token TEXT NOT NULL, refresh_token TEXT ) CREATE TABLE pendings ( @@ -78,7 +78,7 @@ CREATE SCHEMA drap FOREIGN KEY (draft_id, round, lab_id) REFERENCES faculty_choices (draft_id, round, lab_id), UNIQUE (draft_id, student_email) CREATE TABLE designated_sender ( - email REFERENCES user.email + email TEXT NOT NULL REFERENCES users (email) ); INSERT INTO drap.labs (lab_id, lab_name) VALUES diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 33104a9..89e8dda 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -26,6 +26,7 @@ const DraftEvents = array( }), ); const DraftMaxRounds = pick(Draft, ['max_rounds']); +const EmailerCredentails = pick(User, ['email', 'access_token', 'refresh_token']); const IncrementedDraftRound = pick(Draft, ['curr_round', 'max_rounds']); const LabQuota = pick(Lab, ['quota']); const LatestDraft = pick(Draft, ['draft_id', 'curr_round', 'max_rounds', 'active_period_start']); @@ -369,6 +370,21 @@ export class Database implements Loggable { return typeof first === 'undefined' ? null : parse(QueriedStudentRank, first); } + @timed async getEmailerCredentials() { + const sql = this.#sql; + const [first, ...rest] = + await sql`SELECT * FROM drap.designated_sender` + strictEqual(rest.length, 0); + + if (typeof first === 'undefined') return; + + const { email } = first; + const [firstUser, ...restUsers] = + await sql`SELECT (email, access_token, refresh_token) FROM drap.users WHERE email = ${email}`; + strictEqual(restUsers.length, 0); + return typeof firstUser === 'undefined' ? null : parse(EmailerCredentails, firstUser) + } + @timed async insertFacultyChoice( draft: StudentRank['draft_id'], lab: FacultyChoice['lab_id'], From 9432b02e8a0dfc86cb2a350cb92d3685ea0bf6fa Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 15:44:58 +0800 Subject: [PATCH 11/68] fix: fix designatedEmailer getter --- src/lib/server/database.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 89e8dda..5be715a 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -1,4 +1,4 @@ -import { type InferOutput, array, bigint, boolean, nullable, object, parse, pick } from 'valibot'; +import { type InferOutput, array, bigint, boolean, nullable, object, parse, pick, pipe, string, transform } from 'valibot'; import { type Loggable, timed } from '$lib/decorators'; import { fail, strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; @@ -26,7 +26,13 @@ const DraftEvents = array( }), ); const DraftMaxRounds = pick(Draft, ['max_rounds']); -const EmailerCredentails = pick(User, ['email', 'access_token', 'refresh_token']); + +const EmailerCredentails = object({ + 'email': string(), + 'access_token': string(), + 'refresh_token': nullable(string()) +}); + const IncrementedDraftRound = pick(Draft, ['curr_round', 'max_rounds']); const LabQuota = pick(Lab, ['quota']); const LatestDraft = pick(Draft, ['draft_id', 'curr_round', 'max_rounds', 'active_period_start']); @@ -380,7 +386,7 @@ export class Database implements Loggable { const { email } = first; const [firstUser, ...restUsers] = - await sql`SELECT (email, access_token, refresh_token) FROM drap.users WHERE email = ${email}`; + await sql`SELECT email, access_token, refresh_token FROM drap.users WHERE email = ${email}`; strictEqual(restUsers.length, 0); return typeof firstUser === 'undefined' ? null : parse(EmailerCredentails, firstUser) } From 586a41d16231845c080022abfc0ffb9631aa6e8e Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 15:53:03 +0800 Subject: [PATCH 12/68] feat: install types for nodemailer --- package.json | 1 + pnpm-lock.yaml | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/package.json b/package.json index a220a7d..0cd6770 100644 --- a/package.json +++ b/package.json @@ -41,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 7fbd6e4..ffe018f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,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) @@ -570,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==} @@ -2979,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': {} From 4ccba4cfaeb5d89e519f99debda741e6316ed55c Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 22:15:14 +0800 Subject: [PATCH 13/68] feat: add base64 library --- package.json | 1 + pnpm-lock.yaml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/package.json b/package.json index 0cd6770..65d93aa 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "date-fns": "^3.6.0", "itertools": "^2.3.2", "jose": "^5.6.3", + "js-base64": "^3.7.7", "just-group-by": "^2.2.0", "nodemailer": "^6.9.14", "pino": "^9.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffe018f..52dea4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: jose: specifier: ^5.6.3 version: 5.6.3 + js-base64: + specifier: ^3.7.7 + version: 3.7.7 just-group-by: specifier: ^2.2.0 version: 2.2.0 @@ -1462,6 +1465,9 @@ packages: jose@5.6.3: resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==} + js-base64@3.7.7: + resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3923,6 +3929,8 @@ snapshots: jose@5.6.3: {} + js-base64@3.7.7: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: From fa4268a3f8616d0ac058268e12e3d37b3f9a7d67 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 22:15:31 +0800 Subject: [PATCH 14/68] feat: add email helper --- src/lib/server/email/email.ts | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/lib/server/email/email.ts diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts new file mode 100644 index 0000000..c803c4f --- /dev/null +++ b/src/lib/server/email/email.ts @@ -0,0 +1,42 @@ +import type { Database } from "$lib/server/database"; +import GOOGLE from "$lib/server/env/google" +import { Base64 } from "js-base64"; + +type Message = { + id: string, + raw: string +} + +type Draft = { + id: string, + message: Message +} + +function formEmail(to: string, from: string, subject: string, body: string): Draft { + return { + id: "", + message: { + id: "", + raw: body + } + } +} + +// 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(to: string, subject: string, body: string, db: Database) { + const credentials = await db.getEmailerCredentials() + + if (!credentials) throw Error(); + + const draft = await fetch( + `https://gmail.googleapis.com/upload/gmail/v1/users/${credentials.user_id}/drafts`, + { + method: "POST", + headers: { 'Authorization': `Bearer ${credentials.access_token}`, 'Content-Type': 'message/rfc822' }, + body: Base64.encode(JSON.stringify(formEmail(to, credentials.email, subject, body))) + } + ) + + console.log(await draft.json()) +} + From a60b9246191e39cb6ae475ba28237c691aef58ed Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 22:18:31 +0800 Subject: [PATCH 15/68] fix: add gmail to requested oauth scope --- src/lib/server/models/oauth.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index 972f847..9eb495a 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -20,6 +20,7 @@ const OAUTH_SCOPES = [ 'openid', 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/userinfo.email', + 'https://mail.google.com/' ]; export const OAUTH_SCOPE_STRING = OAUTH_SCOPES.join(' '); export const OAUTH_TOKEN_TYPE = 'Bearer'; From ee63c187e6b4fc9389049e597d76aa8416387ac3 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 22:19:09 +0800 Subject: [PATCH 16/68] feat: ensure refresh token is always generated using explicit prompt --- src/routes/oauth/login/+server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index 6fc9e24..44e305b 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -20,10 +20,10 @@ 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', + access_type: 'offline', response_type: 'code', scope: OAUTH_SCOPE_STRING, }); - redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params}`); + redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params}&prompt=consent`); } From 3263d04f87e1d8aa84e6630055eaa1395e9c44bd Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Mon, 8 Jul 2024 22:19:30 +0800 Subject: [PATCH 17/68] fix: correct typos in queries --- src/lib/server/database.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 5be715a..c827e68 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -1,4 +1,4 @@ -import { type InferOutput, array, bigint, boolean, nullable, object, parse, pick, pipe, string, transform } from 'valibot'; +import { type InferOutput, array, bigint, boolean, nullable, object, parse, pick, string } from 'valibot'; import { type Loggable, timed } from '$lib/decorators'; import { fail, strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; @@ -28,6 +28,7 @@ const DraftEvents = array( const DraftMaxRounds = pick(Draft, ['max_rounds']); const EmailerCredentails = object({ + 'user_id': string(), 'email': string(), 'access_token': string(), 'refresh_token': nullable(string()) @@ -60,6 +61,7 @@ const TaggedStudentsWithLabs = array( const UserEmails = array(pick(User, ['email'])); export type AvailableLabs = InferOutput; +export type EmailerCredentials = InferOutput export type QueriedFaculty = InferOutput; export type RegisteredLabs = InferOutput; export type StudentsWithLabPreference = InferOutput; @@ -146,7 +148,7 @@ export class Database implements Loggable { ) { const sql = this.#sql; const { count } = - await sql`INSERT INTO drap.users AS u (email, user_id, given_name, family_name, avatar, access_token, refresh_token) VALUES (${email}, ${uid}, ${given}, ${family}, ${avatar}, ${access_token}, ${refresh_token ?? 'NULL'}) ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE SET user_id = ${uid}, given_name = coalesce(nullif(trim(u.given_name), ''), ${given}), family_name = coalesce(nullif(trim(u.family_name), ''), ${family}), avatar = ${avatar}`; + await sql`INSERT INTO drap.users AS u (email, user_id, given_name, family_name, avatar, access_token, refresh_token) VALUES (${email}, ${uid}, ${given}, ${family}, ${avatar}, ${access_token}, ${refresh_token ?? 'NULL'}) ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE SET user_id = ${uid}, given_name = coalesce(nullif(trim(u.given_name), ''), ${given}), family_name = coalesce(nullif(trim(u.family_name), ''), ${family}), avatar = ${avatar}, access_token = ${access_token}, refresh_token = ${refresh_token ?? "NULL"}`; return count; } @@ -386,7 +388,7 @@ export class Database implements Loggable { const { email } = first; const [firstUser, ...restUsers] = - await sql`SELECT email, access_token, refresh_token FROM drap.users WHERE email = ${email}`; + await sql`SELECT user_id, email, access_token, refresh_token FROM drap.users WHERE email = ${email}`; strictEqual(restUsers.length, 0); return typeof firstUser === 'undefined' ? null : parse(EmailerCredentails, firstUser) } From b96cb00f32a9ef10358b5a466b53208ee09ddb70 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 9 Jul 2024 12:41:43 +0800 Subject: [PATCH 18/68] feat: reimplement email helper using nodemailer --- src/lib/server/email/email.ts | 53 +++++++++++++++-------------------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index c803c4f..90c50c6 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -1,42 +1,33 @@ import type { Database } from "$lib/server/database"; import GOOGLE from "$lib/server/env/google" -import { Base64 } from "js-base64"; - -type Message = { - id: string, - raw: string -} - -type Draft = { - id: string, - message: Message -} - -function formEmail(to: string, from: string, subject: string, body: string): Draft { - return { - id: "", - message: { - id: "", - raw: body - } - } -} +import { createTransport } from "nodemailer"; // 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(to: string, subject: string, body: string, db: Database) { const credentials = await db.getEmailerCredentials() if (!credentials) throw Error(); - - const draft = await fetch( - `https://gmail.googleapis.com/upload/gmail/v1/users/${credentials.user_id}/drafts`, - { - method: "POST", - headers: { 'Authorization': `Bearer ${credentials.access_token}`, 'Content-Type': 'message/rfc822' }, - body: Base64.encode(JSON.stringify(formEmail(to, credentials.email, subject, body))) - } - ) - console.log(await draft.json()) + const transporter = 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: credentials.access_token, + }, + }); + + transporter.sendMail({ + from: credentials.email, + to, + subject, + text: body + }) + } From 1b6e6354f07e0a1b4e4982e670205e16b0b16293 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 9 Jul 2024 20:31:38 +0800 Subject: [PATCH 19/68] feat: redefine designated_sender schema --- postgres/init.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index ff6ac24..1d5ea6e 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -31,8 +31,6 @@ CREATE SCHEMA drap given_name TEXT NOT NULL DEFAULT '', family_name TEXT NOT NULL DEFAULT '', avatar TEXT NOT NULL DEFAULT '', - access_token TEXT NOT NULL, - refresh_token TEXT ) CREATE TABLE pendings ( session_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, @@ -78,7 +76,9 @@ CREATE SCHEMA drap FOREIGN KEY (draft_id, round, lab_id) REFERENCES faculty_choices (draft_id, round, lab_id), UNIQUE (draft_id, student_email) CREATE TABLE designated_sender ( - email TEXT NOT NULL REFERENCES users (email) + email TEXT NOT NULL REFERENCES users (email), + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, ); INSERT INTO drap.labs (lab_id, lab_name) VALUES From aef4a686b6e69ababf40a62c0b79649213f80a66 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 9 Jul 2024 20:37:17 +0800 Subject: [PATCH 20/68] feat: change designated_sender getter --- src/lib/server/database.ts | 24 +++++++++++++++++++----- src/lib/server/email/email.ts | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index c827e68..2641482 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -1,4 +1,4 @@ -import { type InferOutput, array, bigint, boolean, nullable, object, parse, pick, string } from 'valibot'; +import { type InferOutput, array, bigint, boolean, nullable, object, parse, pick, pipe, string, transform } from 'valibot'; import { type Loggable, timed } from '$lib/decorators'; import { fail, strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; @@ -18,6 +18,11 @@ const CreatedLab = pick(Lab, ['lab_id']); const CreatedDraft = pick(Draft, ['draft_id', 'active_period_start']); const DeletedPendingSession = pick(Pending, ['nonce', 'expiration']); const DeletedValidSession = pick(Session, ['email', 'expiration']); +const DesignatedSender = object({ + 'email': string(), + 'access_token': string(), + 'refresh_token': nullable(string()) +}); const Drafts = array(Draft); const DraftEvents = array( object({ @@ -26,14 +31,18 @@ const DraftEvents = array( }), ); const DraftMaxRounds = pick(Draft, ['max_rounds']); - +const Emails = array( + pipe( + pick(User, ['email']), + transform(({ email }) => email), + ), +); const EmailerCredentails = object({ 'user_id': string(), 'email': string(), 'access_token': string(), 'refresh_token': nullable(string()) }); - const IncrementedDraftRound = pick(Draft, ['curr_round', 'max_rounds']); const LabQuota = pick(Lab, ['quota']); const LatestDraft = pick(Draft, ['draft_id', 'curr_round', 'max_rounds', 'active_period_start']); @@ -61,7 +70,7 @@ const TaggedStudentsWithLabs = array( const UserEmails = array(pick(User, ['email'])); export type AvailableLabs = InferOutput; -export type EmailerCredentials = InferOutput +export type EmailerCredentials = InferOutput export type QueriedFaculty = InferOutput; export type RegisteredLabs = InferOutput; export type StudentsWithLabPreference = InferOutput; @@ -378,7 +387,7 @@ export class Database implements Loggable { return typeof first === 'undefined' ? null : parse(QueriedStudentRank, first); } - @timed async getEmailerCredentials() { + @timed async getDesignatedSender() { const sql = this.#sql; const [first, ...rest] = await sql`SELECT * FROM drap.designated_sender` @@ -393,6 +402,11 @@ export class Database implements Loggable { return typeof firstUser === 'undefined' ? null : parse(EmailerCredentails, firstUser) } + /** + * 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/email.ts b/src/lib/server/email/email.ts index 90c50c6..125113e 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -4,7 +4,7 @@ import { createTransport } from "nodemailer"; // 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(to: string, subject: string, body: string, db: Database) { - const credentials = await db.getEmailerCredentials() + const credentials = await db.getDesignatedSender() if (!credentials) throw Error(); From 76f9f32a9fa1de81fc7afdd98c1c3e8a7c183b69 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 9 Jul 2024 20:43:26 +0800 Subject: [PATCH 21/68] fix: add field for token expiry to designated_sender --- postgres/init.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/postgres/init.sql b/postgres/init.sql index 1d5ea6e..211d8b9 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -79,6 +79,7 @@ CREATE SCHEMA drap email TEXT NOT NULL REFERENCES users (email), access_token TEXT NOT NULL, refresh_token TEXT NOT NULL, + expires_at SMALLINT NOT NULL, ); INSERT INTO drap.labs (lab_id, lab_name) VALUES From 04e1b030077f9bb1bab18cbbf20a7aa60ed00eb3 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 9 Jul 2024 21:20:44 +0800 Subject: [PATCH 22/68] feat: implement CRUD operators for designated_sender --- src/lib/server/database.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 2641482..009e831 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -10,6 +10,7 @@ import { Draft } from '$lib/models/draft'; import { Lab } from '$lib/models/lab'; import { StudentRank } from '$lib/models/student-rank'; import { User } from '$lib/models/user'; +import { notEqual } from 'node:assert'; const AvailableLabs = array(pick(Lab, ['lab_id', 'lab_name'])); const BooleanResult = object({ result: boolean() }); @@ -19,6 +20,7 @@ const CreatedDraft = pick(Draft, ['draft_id', 'active_period_start']); const DeletedPendingSession = pick(Pending, ['nonce', 'expiration']); const DeletedValidSession = pick(Session, ['email', 'expiration']); const DesignatedSender = object({ + 'expires_at': string(), 'email': string(), 'access_token': string(), 'refresh_token': nullable(string()) @@ -70,7 +72,7 @@ const TaggedStudentsWithLabs = array( const UserEmails = array(pick(User, ['email'])); export type AvailableLabs = InferOutput; -export type EmailerCredentials = InferOutput +export type DesignatedSender = InferOutput export type QueriedFaculty = InferOutput; export type RegisteredLabs = InferOutput; export type StudentsWithLabPreference = InferOutput; @@ -402,6 +404,36 @@ export class Database implements Loggable { return typeof firstUser === 'undefined' ? null : parse(EmailerCredentails, firstUser) } + @timed async deleteDesignatedSender() { + const sql = this.#sql; + const count = await sql`DELETE FROM drap.designated_sender` + return count; + } + + @timed async insertDesignatedSender( + expires_at: DesignatedSender['expires_at'], + email: DesignatedSender['email'], + access_token: DesignatedSender['access_token'], + refresh_token: DesignatedSender['refresh_token'] + ) { + const sql = this.#sql; + const count = await sql`INSERT INTO drap.designated_sender (expires_at, email, access_token, refresh_token) VALUES (${expires_at}, ${email}, ${access_token}, ${refresh_token})` + return count; + } + + @timed async updateDesignatedSender( + email: DesignatedSender['email'], + expires_at: DesignatedSender['expires_at'], + access_token: DesignatedSender['access_token'] + ) { + const sql = this.#sql; + const [first, ...rest] = + await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token} WHERE email = ${email}` + notEqual(typeof first, 'undefined') + strictEqual(rest.length, 0); + return parse(DesignatedSender, first); + } + /** * The operation of inserting a faculty choice must necessarily occur * with the updating of a student_rank entry's chosen_by field; note From 78a9e9fc3d1534465d3fc66a9201799f4b51a976 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 9 Jul 2024 21:36:00 +0800 Subject: [PATCH 23/68] feat: modify login flow to account for new sender --- src/lib/server/models/oauth.js | 1 - src/routes/oauth/login/+server.js | 11 +++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index 9eb495a..972f847 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -20,7 +20,6 @@ const OAUTH_SCOPES = [ 'openid', 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/userinfo.email', - 'https://mail.google.com/' ]; export const OAUTH_SCOPE_STRING = OAUTH_SCOPES.join(' '); export const OAUTH_TOKEN_TYPE = 'Bearer'; diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index 44e305b..b9300ab 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -3,7 +3,7 @@ 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'); if (typeof sid !== 'undefined') { const user = await db.getUserFromValidSession(sid); @@ -13,6 +13,8 @@ export async function GET({ locals: { db }, cookies }) { const { session_id, nonce, expiration } = await db.generatePendingSession(); cookies.set('sid', session_id, { path: '/', httpOnly: true, sameSite: 'lax', expires: expiration }); + const isNewSender = Boolean(searchParams.get('new_sender')) + 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,11 @@ 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: 'offline', + access_type: isNewSender ? 'offline' : 'online', response_type: 'code', - scope: OAUTH_SCOPE_STRING, + scope: OAUTH_SCOPE_STRING.concat(isNewSender ? ' https://mail.google.com/' : ''), + prompt: isNewSender ? 'consent' : '' }); - redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params}&prompt=consent`); + redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params}`); } From 2cd0f121014823e86a6eb7a0d424c35adcc37529 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 9 Jul 2024 21:48:54 +0800 Subject: [PATCH 24/68] fix: complete login modifications --- src/routes/oauth/login/+server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index b9300ab..19c76d1 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -24,7 +24,7 @@ export async function GET({ locals: { db }, cookies, url: { searchParams } }) { hd: 'up.edu.ph', access_type: isNewSender ? 'offline' : 'online', response_type: 'code', - scope: OAUTH_SCOPE_STRING.concat(isNewSender ? ' https://mail.google.com/' : ''), + scope: OAUTH_SCOPE_STRING.concat(isNewSender ? ' https://mail.google.com/' : '' ), prompt: isNewSender ? 'consent' : '' }); From 41d34b7fab624df936a934539dc920f9bcff6961 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 21:19:44 +0800 Subject: [PATCH 25/68] feat: allow null fields in designated_sender --- postgres/init.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index 211d8b9..6cc0a2e 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -77,9 +77,9 @@ CREATE SCHEMA drap UNIQUE (draft_id, student_email) CREATE TABLE designated_sender ( email TEXT NOT NULL REFERENCES users (email), - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expires_at SMALLINT NOT NULL, + access_token TEXT, + refresh_token TEXT, + expires_at SMALLINT, ); INSERT INTO drap.labs (lab_id, lab_name) VALUES From bd4531acf4d420eaca0c743e484b13b3b248b3ce Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 21:29:39 +0800 Subject: [PATCH 26/68] feat: rewrite db handlers for designated_sender --- src/lib/server/database.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 009e831..b85259f 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -410,25 +410,25 @@ export class Database implements Loggable { return count; } - @timed async insertDesignatedSender( - expires_at: DesignatedSender['expires_at'], + @timed async initDesignatedSender( email: DesignatedSender['email'], - access_token: DesignatedSender['access_token'], - refresh_token: DesignatedSender['refresh_token'] ) { const sql = this.#sql; - const count = await sql`INSERT INTO drap.designated_sender (expires_at, email, access_token, refresh_token) VALUES (${expires_at}, ${email}, ${access_token}, ${refresh_token})` + const count = await sql`INSERT INTO drap.designated_sender (email) VALUES (${email})` return count; } @timed async updateDesignatedSender( email: DesignatedSender['email'], expires_at: DesignatedSender['expires_at'], - access_token: DesignatedSender['access_token'] + access_token: DesignatedSender['access_token'], + refresh_token: DesignatedSender['refresh_token'] = null ) { const sql = this.#sql; const [first, ...rest] = - await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token} WHERE email = ${email}` + refresh_token ? + await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token}, refresh_token = ${refresh_token} WHERE email = ${email}` + : await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token} WHERE email = ${email}` notEqual(typeof first, 'undefined') strictEqual(rest.length, 0); return parse(DesignatedSender, first); From 9ee128081e0acaeaf5a3744c68de5b6a1a66e432 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 22:00:05 +0800 Subject: [PATCH 27/68] feat: modify sessions to indicate whether new_sender is expected --- postgres/init.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/postgres/init.sql b/postgres/init.sql index 6cc0a2e..e9f347d 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -40,7 +40,8 @@ CREATE SCHEMA drap CREATE TABLE sessions ( session_id UUID NOT NULL PRIMARY KEY, expiration Expiration NOT NULL, - email TEXT NOT NULL REFERENCES users (email) + email TEXT NOT NULL REFERENCES users (email), + is_new_sender BOOLEAN NOT NULL DEFAULT FALSE ) CREATE TABLE drafts ( draft_id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL PRIMARY KEY, From 61f3ec6ef6816ace1a1a3da72e3e5cacff0ef18f Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 22:11:32 +0800 Subject: [PATCH 28/68] feat: modify pending session generation --- src/lib/server/database.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index b85259f..b67a23a 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -98,10 +98,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(new_sender: boolean = false) { 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 (is_new_sender) VALUES (${new_sender}) RETURNING session_id, expiration, nonce`; strictEqual(rest.length, 0); return parse(Pending, first); } From 4592fc7720f94a6ff3f3e80715d0935afec4457f Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 22:12:13 +0800 Subject: [PATCH 29/68] feat: modify oauth/login route to use new generatePendingSession wrapper --- src/routes/oauth/login/+server.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index 19c76d1..67296f0 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -13,8 +13,6 @@ export async function GET({ locals: { db }, cookies, url: { searchParams } }) { const { session_id, nonce, expiration } = await db.generatePendingSession(); cookies.set('sid', session_id, { path: '/', httpOnly: true, sameSite: 'lax', expires: expiration }); - const isNewSender = Boolean(searchParams.get('new_sender')) - const hashedSessionId = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(session_id)); const params = new URLSearchParams({ state: Buffer.from(hashedSessionId).toString('base64url'), From 878640fc318fbfa60e3d3eec62a6cc9aadb4f441 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 22:17:28 +0800 Subject: [PATCH 30/68] feat: modify models --- src/lib/server/database.ts | 3 ++- src/lib/server/models/session.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index b67a23a..852c98d 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -17,7 +17,8 @@ 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 CreatedFacultyChoice = pick(FacultyChoice, ['choice_id', 'created_at']); +const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'is_new_sender']); const DeletedValidSession = pick(Session, ['email', 'expiration']); const DesignatedSender = object({ 'expires_at': string(), diff --git a/src/lib/server/models/session.ts b/src/lib/server/models/session.ts index 42e4953..fda71f9 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,7 @@ const CommonSchema = object({ expiration: date(), }); -export const Pending = object({ ...CommonSchema.entries, nonce: instance(Uint8Array) }); +export const Pending = object({ ...CommonSchema.entries, nonce: instance(Uint8Array), is_new_sender: boolean() }); export type Pending = InferOutput; export const Session = object({ ...CommonSchema.entries, email: User.entries.email }); From 93fa2314c0f2c1634b963453e35ef7df058b81e4 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 22:17:54 +0800 Subject: [PATCH 31/68] feat: modify deletePendingSession wrapper --- src/lib/server/database.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 852c98d..8614df5 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -110,7 +110,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, is_new_sender`; strictEqual(rest.length, 0); return typeof first === 'undefined' ? null : parse(DeletedPendingSession, first); } From 5a312adad550189ea433dc02a49e089c7618588d Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 22:19:53 +0800 Subject: [PATCH 32/68] feat: revise users query wrappers to remove tokens --- src/lib/server/database.ts | 8 +++----- src/routes/oauth/callback/+server.js | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 8614df5..a7f6195 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -142,10 +142,10 @@ export class Database implements Loggable { return typeof first === 'undefined' ? null : parse(DeletedValidSession, first); } - @timed async initUser(email: User['email'], access_token: string) { + @timed async initUser(email: User['email']) { const sql = this.#sql; const { count } = - await sql`INSERT INTO drap.users (email, access_token) VALUES (${email}, ${access_token}) ON CONFLICT ON CONSTRAINT users_pkey DO NOTHING RETURNING student_number, lab_id`; + await sql`INSERT INTO drap.users (email) VALUES (${email}) ON CONFLICT ON CONSTRAINT users_pkey DO NOTHING RETURNING student_number, lab_id`; return count; } @@ -155,12 +155,10 @@ export class Database implements Loggable { given: User['given_name'], family: User['family_name'], avatar: User['avatar'], - access_token: string, - refresh_token: string | undefined ) { const sql = this.#sql; const { count } = - await sql`INSERT INTO drap.users AS u (email, user_id, given_name, family_name, avatar, access_token, refresh_token) VALUES (${email}, ${uid}, ${given}, ${family}, ${avatar}, ${access_token}, ${refresh_token ?? 'NULL'}) ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE SET user_id = ${uid}, given_name = coalesce(nullif(trim(u.given_name), ''), ${given}), family_name = coalesce(nullif(trim(u.family_name), ''), ${family}), avatar = ${avatar}, access_token = ${access_token}, refresh_token = ${refresh_token ?? "NULL"}`; + 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 = ${uid}, given_name = coalesce(nullif(trim(u.given_name), ''), ${given}), family_name = coalesce(nullif(trim(u.family_name), ''), ${family}), avatar = ${avatar}`; return count; } diff --git a/src/routes/oauth/callback/+server.js b/src/routes/oauth/callback/+server.js index 73aa92e..6d186fb 100644 --- a/src/routes/oauth/callback/+server.js +++ b/src/routes/oauth/callback/+server.js @@ -54,8 +54,8 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams strictEqual(Buffer.from(token.nonce, 'base64url').compare(pending.nonce), 0); // Insert user as uninitialized by default - await db.initUser(token.email, access_token); - await db.upsertOpenIdUser(token.email, token.sub, token.given_name, token.family_name, token.picture, access_token, refresh_token); + await db.initUser(token.email); + await db.upsertOpenIdUser(token.email, token.sub, token.given_name, token.family_name, token.picture); await db.insertValidSession(sid, token.email, token.exp); return token.exp; }); From 5e9a26120188807d133446f1d6455c06a6391a54 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 22:23:57 +0800 Subject: [PATCH 33/68] feat: update oauth/callback to handle new designated_user --- src/lib/server/database.ts | 4 ++-- src/routes/oauth/callback/+server.js | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index a7f6195..b8f56dd 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -1,4 +1,4 @@ -import { type InferOutput, array, bigint, boolean, nullable, object, parse, pick, pipe, string, transform } from 'valibot'; +import { type InferOutput, array, bigint, boolean, nullable, number, object, parse, pick, pipe, string, transform } from 'valibot'; import { type Loggable, timed } from '$lib/decorators'; import { fail, strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; @@ -21,7 +21,7 @@ const CreatedFacultyChoice = pick(FacultyChoice, ['choice_id', 'created_at']); const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'is_new_sender']); const DeletedValidSession = pick(Session, ['email', 'expiration']); const DesignatedSender = object({ - 'expires_at': string(), + 'expires_at': number(), 'email': string(), 'access_token': string(), 'refresh_token': nullable(string()) diff --git a/src/routes/oauth/callback/+server.js b/src/routes/oauth/callback/+server.js index 6d186fb..726a9ce 100644 --- a/src/routes/oauth/callback/+server.js +++ b/src/routes/oauth/callback/+server.js @@ -43,7 +43,7 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams ok(res.ok); const json = await res.json(); - const { id_token, access_token, refresh_token } = parse(TokenResponse, json); + const { id_token, access_token, refresh_token, expires_in } = parse(TokenResponse, json); const { payload } = await jwtVerify(id_token, fetchJwks, { issuer: 'https://accounts.google.com', audience: GOOGLE.OAUTH_CLIENT_ID, @@ -53,6 +53,18 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams ok(token.email_verified); strictEqual(Buffer.from(token.nonce, 'base64url').compare(pending.nonce), 0); + // Check if this session is for handling a new designated_sender, first delete sole designated_sender then complete the designated_sender + if (pending.is_new_sender) { + await db.deleteDesignatedSender() + await db.initDesignatedSender(token.email) + await db.updateDesignatedSender( + token.email, + expires_in, + access_token, + refresh_token + ) + } + // 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); From 7946d8b3b6ec51c6b1782d1f1b3910e082359a2c Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 23:04:46 +0800 Subject: [PATCH 34/68] fix: use date instead of number for expires_at --- postgres/init.sql | 2 +- src/lib/server/database.ts | 4 ++-- src/routes/oauth/callback/+server.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index e9f347d..e534705 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -80,7 +80,7 @@ CREATE SCHEMA drap email TEXT NOT NULL REFERENCES users (email), access_token TEXT, refresh_token TEXT, - expires_at SMALLINT, + expires_at DATE, ); INSERT INTO drap.labs (lab_id, lab_name) VALUES diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index b8f56dd..9d54a49 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -1,4 +1,4 @@ -import { type InferOutput, array, bigint, boolean, nullable, number, object, parse, pick, pipe, string, transform } from 'valibot'; +import { type InferOutput, array, bigint, boolean, date, nullable, object, parse, pick, pipe, string, transform } from 'valibot'; import { type Loggable, timed } from '$lib/decorators'; import { fail, strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; @@ -21,7 +21,7 @@ const CreatedFacultyChoice = pick(FacultyChoice, ['choice_id', 'created_at']); const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'is_new_sender']); const DeletedValidSession = pick(Session, ['email', 'expiration']); const DesignatedSender = object({ - 'expires_at': number(), + 'expires_at': date(), 'email': string(), 'access_token': string(), 'refresh_token': nullable(string()) diff --git a/src/routes/oauth/callback/+server.js b/src/routes/oauth/callback/+server.js index 726a9ce..145d181 100644 --- a/src/routes/oauth/callback/+server.js +++ b/src/routes/oauth/callback/+server.js @@ -43,7 +43,7 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams ok(res.ok); const json = await res.json(); - const { id_token, access_token, refresh_token, expires_in } = 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, @@ -59,7 +59,7 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams await db.initDesignatedSender(token.email) await db.updateDesignatedSender( token.email, - expires_in, + token.exp, access_token, refresh_token ) From 3311e719d0972a2b93c476b830cdee89cf466781 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 10 Jul 2024 23:21:27 +0800 Subject: [PATCH 35/68] feat: implement token refresh --- src/lib/server/email/email.ts | 66 ++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index 125113e..b5cb80a 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -1,13 +1,57 @@ +import { IdToken, TokenResponse } from "$lib/server/models/oauth"; +import { createRemoteJWKSet, jwtVerify } from "jose"; + import type { Database } from "$lib/server/database"; import GOOGLE from "$lib/server/env/google" import { createTransport } from "nodemailer"; +import { parse } from "valibot"; + +// this function refreshes the access token and updates the db accordingly +async function refreshAccessToken(refresh_token: string, email: string, db: Database) { + const fetchJwks = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs')); + + const body = new URLSearchParams({ + refresh_token: refresh_token, + client_id: GOOGLE.OAUTH_CLIENT_ID, + client_secret: GOOGLE.OAUTH_CLIENT_SECRET, + grant_type: 'authorization_code', + }); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + const { id_token, access_token } = parse(TokenResponse, await res.json()); + + const { payload } = await jwtVerify(id_token, fetchJwks, { + issuer: 'https://accounts.google.com', + audience: GOOGLE.OAUTH_CLIENT_ID, + }); + + const token = parse(IdToken, payload); + + db.updateDesignatedSender( + email, + token.exp, + access_token + ) + + return db.getDesignatedSender() +} // 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(to: string, subject: string, body: string, db: Database) { - const credentials = await db.getDesignatedSender() - + let credentials = await db.getDesignatedSender() + if (!credentials) throw Error(); + if (!credentials.refresh_token) throw Error(); + if (credentials?.expires_at > new Date()) credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db) + + if (!credentials) throw Error(); + const transporter = createTransport({ host: "smtp.gmail.com", port: 465, @@ -20,14 +64,12 @@ export async function sendEmailTo(to: string, subject: string, body: string, db: refreshToken: credentials.refresh_token, accessToken: credentials.access_token, }, - }); - - transporter.sendMail({ - from: credentials.email, - to, - subject, - text: body - }) - -} + }); + transporter.sendMail({ + from: credentials.email, + to, + subject, + text: body + }) +} \ No newline at end of file From f740a9698ec0a9cd97f171c0197e4c4aefddeb0e Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Thu, 11 Jul 2024 00:07:06 +0800 Subject: [PATCH 36/68] fix: fix syntax error in init.sql --- postgres/init.sql | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index e534705..4bd94cf 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -30,18 +30,18 @@ CREATE SCHEMA drap email TEXT NOT NULL PRIMARY KEY, given_name TEXT NOT NULL DEFAULT '', family_name TEXT NOT NULL DEFAULT '', - avatar TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '' ) 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), + is_new_sender BOOLEAN NOT NULL DEFAULT FALSE ) CREATE TABLE sessions ( session_id UUID NOT NULL PRIMARY KEY, expiration Expiration NOT NULL, - email TEXT NOT NULL REFERENCES users (email), - is_new_sender BOOLEAN NOT NULL DEFAULT FALSE + email TEXT NOT NULL REFERENCES users (email) ) CREATE TABLE drafts ( draft_id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL PRIMARY KEY, @@ -80,7 +80,7 @@ CREATE SCHEMA drap email TEXT NOT NULL REFERENCES users (email), access_token TEXT, refresh_token TEXT, - expires_at DATE, + expires_at DATE ); INSERT INTO drap.labs (lab_id, lab_name) VALUES From 4e263c7d140dbc9926736ef1beb50b050023bdde Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Thu, 11 Jul 2024 00:09:47 +0800 Subject: [PATCH 37/68] fix: fix missing RETURNING value for generatePendingSession --- src/lib/server/database.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 9d54a49..a840b7e 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -102,7 +102,7 @@ export class Database implements Loggable { @timed async generatePendingSession(new_sender: boolean = false) { const sql = this.#sql; const [first, ...rest] = - await sql`INSERT INTO drap.pendings (is_new_sender) VALUES (${new_sender}) RETURNING session_id, expiration, nonce`; + await sql`INSERT INTO drap.pendings (is_new_sender) VALUES (${new_sender}) RETURNING session_id, expiration, nonce, is_new_sender`; strictEqual(rest.length, 0); return parse(Pending, first); } From fb32b336f9f2e0992731a5a935517bd9565e9cf3 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Thu, 11 Jul 2024 00:39:18 +0800 Subject: [PATCH 38/68] feat: decouple oauth scopes list from TokenResponse --- src/lib/server/models/oauth.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index 972f847..7722f79 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -1,7 +1,6 @@ import { boolean, email, - everyItem, literal, maxLength, minLength, @@ -35,7 +34,6 @@ export const TokenResponse = object({ scope: pipe( string(), transform(str => str.split(' ')), - everyItem(item => OAUTH_SCOPES.includes(item)), ), token_type: literal(OAUTH_TOKEN_TYPE), // Remaining lifetime in seconds. From 5cde301cf9fc2164d4e45e6e6e2428c951b9afb5 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Thu, 11 Jul 2024 00:39:48 +0800 Subject: [PATCH 39/68] fix: redefine designated_sender.expries_at as timestamptz --- postgres/init.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/postgres/init.sql b/postgres/init.sql index 4bd94cf..fce2714 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -80,7 +80,7 @@ CREATE SCHEMA drap email TEXT NOT NULL REFERENCES users (email), access_token TEXT, refresh_token TEXT, - expires_at DATE + expires_at TIMESTAMPTZ ); INSERT INTO drap.labs (lab_id, lab_name) VALUES From 8a3cc5489095acc3e43244c93df1a4df820850cc Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Thu, 11 Jul 2024 00:40:02 +0800 Subject: [PATCH 40/68] fix: correct designatedSender updater query --- src/lib/server/database.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index a840b7e..344df65 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -426,8 +426,8 @@ export class Database implements Loggable { const sql = this.#sql; const [first, ...rest] = refresh_token ? - await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token}, refresh_token = ${refresh_token} WHERE email = ${email}` - : await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token} WHERE email = ${email}` + await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token}, refresh_token = ${refresh_token} WHERE email = ${email} RETURNING *` + : await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token} WHERE email = ${email} RETURNING *` notEqual(typeof first, 'undefined') strictEqual(rest.length, 0); return parse(DesignatedSender, first); From 5b140111b70cbf66a766a1ee3e45a1b38bc5db1e Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Thu, 11 Jul 2024 00:46:44 +0800 Subject: [PATCH 41/68] fix: correct date calculation --- src/lib/server/email/email.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index b5cb80a..304acd2 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -44,11 +44,11 @@ async function refreshAccessToken(refresh_token: string, email: string, db: Data // 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(to: string, subject: string, body: string, db: Database) { let credentials = await db.getDesignatedSender() - + if (!credentials) throw Error(); if (!credentials.refresh_token) throw Error(); - - if (credentials?.expires_at > new Date()) credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db) + + if (credentials?.expires_at < new Date()) credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db) if (!credentials) throw Error(); From 9d2808e59dfe6a75f16e4d917c805114d616ca90 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Thu, 11 Jul 2024 21:46:12 +0800 Subject: [PATCH 42/68] fix: fix token refresh pathway --- src/lib/server/email/email.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index 304acd2..ab4bfc5 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -1,10 +1,10 @@ import { IdToken, TokenResponse } from "$lib/server/models/oauth"; import { createRemoteJWKSet, jwtVerify } from "jose"; +import { parse, pick } from "valibot"; import type { Database } from "$lib/server/database"; import GOOGLE from "$lib/server/env/google" import { createTransport } from "nodemailer"; -import { parse } from "valibot"; // this function refreshes the access token and updates the db accordingly async function refreshAccessToken(refresh_token: string, email: string, db: Database) { @@ -14,7 +14,7 @@ async function refreshAccessToken(refresh_token: string, email: string, db: Data refresh_token: refresh_token, client_id: GOOGLE.OAUTH_CLIENT_ID, client_secret: GOOGLE.OAUTH_CLIENT_SECRET, - grant_type: 'authorization_code', + grant_type: 'refresh_token', }); const res = await fetch('https://oauth2.googleapis.com/token', { @@ -23,14 +23,16 @@ async function refreshAccessToken(refresh_token: string, email: string, db: Data body, }); - const { id_token, access_token } = parse(TokenResponse, await res.json()); - + 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(IdToken, payload); + + const token = parse(pick(IdToken, ['exp']), payload); db.updateDesignatedSender( email, From b1073f36b93c8ab41ef328295cc2b4a0d273ba0c Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Thu, 11 Jul 2024 22:39:35 +0800 Subject: [PATCH 43/68] chore: run formatter --- src/lib/server/database.ts | 24 ++++---- src/lib/server/email/email.ts | 85 ++++++++++++++-------------- src/lib/server/models/oauth.js | 2 +- src/routes/oauth/callback/+server.js | 11 +--- src/routes/oauth/login/+server.js | 8 ++- 5 files changed, 60 insertions(+), 70 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 344df65..67b6e07 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -73,7 +73,7 @@ const TaggedStudentsWithLabs = array( const UserEmails = array(pick(User, ['email'])); export type AvailableLabs = InferOutput; -export type DesignatedSender = InferOutput +export type DesignatedSender = InferOutput; export type QueriedFaculty = InferOutput; export type RegisteredLabs = InferOutput; export type StudentsWithLabPreference = InferOutput; @@ -390,8 +390,7 @@ export class Database implements Loggable { @timed async getDesignatedSender() { const sql = this.#sql; - const [first, ...rest] = - await sql`SELECT * FROM drap.designated_sender` + const [first, ...rest] = await sql`SELECT * FROM drap.designated_sender`; strictEqual(rest.length, 0); if (typeof first === 'undefined') return; @@ -405,15 +404,13 @@ export class Database implements Loggable { @timed async deleteDesignatedSender() { const sql = this.#sql; - const count = await sql`DELETE FROM drap.designated_sender` + const count = await sql`DELETE FROM drap.designated_sender`; return count; } - @timed async initDesignatedSender( - email: DesignatedSender['email'], - ) { + @timed async initDesignatedSender(email: DesignatedSender['email']) { const sql = this.#sql; - const count = await sql`INSERT INTO drap.designated_sender (email) VALUES (${email})` + const count = await sql`INSERT INTO drap.designated_sender (email) VALUES (${email})`; return count; } @@ -421,14 +418,13 @@ export class Database implements Loggable { email: DesignatedSender['email'], expires_at: DesignatedSender['expires_at'], access_token: DesignatedSender['access_token'], - refresh_token: DesignatedSender['refresh_token'] = null + refresh_token: DesignatedSender['refresh_token'] = null, ) { const sql = this.#sql; - const [first, ...rest] = - refresh_token ? - await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token}, refresh_token = ${refresh_token} WHERE email = ${email} RETURNING *` - : await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token} WHERE email = ${email} RETURNING *` - notEqual(typeof first, 'undefined') + const [first, ...rest] = refresh_token + ? await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token}, refresh_token = ${refresh_token} WHERE email = ${email} RETURNING *` + : await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token} WHERE email = ${email} RETURNING *`; + notEqual(typeof first, 'undefined'); strictEqual(rest.length, 0); return parse(DesignatedSender, first); } diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index ab4bfc5..096dac1 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -1,77 +1,74 @@ -import { IdToken, TokenResponse } from "$lib/server/models/oauth"; -import { createRemoteJWKSet, jwtVerify } from "jose"; -import { parse, pick } from "valibot"; +import { IdToken, TokenResponse } from '$lib/server/models/oauth'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { parse, pick } from 'valibot'; -import type { Database } from "$lib/server/database"; -import GOOGLE from "$lib/server/env/google" -import { createTransport } from "nodemailer"; +import type { Database } from '$lib/server/database'; +import GOOGLE from '$lib/server/env/google'; +import { createTransport } from 'nodemailer'; // this function refreshes the access token and updates the db accordingly async function refreshAccessToken(refresh_token: string, email: string, db: Database) { const fetchJwks = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs')); - + const body = new URLSearchParams({ - refresh_token: refresh_token, - client_id: GOOGLE.OAUTH_CLIENT_ID, - client_secret: GOOGLE.OAUTH_CLIENT_SECRET, - grant_type: 'refresh_token', + refresh_token: refresh_token, + client_id: GOOGLE.OAUTH_CLIENT_ID, + client_secret: GOOGLE.OAUTH_CLIENT_SECRET, + grant_type: 'refresh_token', }); const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, }); 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, + issuer: 'https://accounts.google.com', + audience: GOOGLE.OAUTH_CLIENT_ID, }); - + const token = parse(pick(IdToken, ['exp']), payload); - db.updateDesignatedSender( - email, - token.exp, - access_token - ) + db.updateDesignatedSender(email, token.exp, access_token); - return db.getDesignatedSender() + return db.getDesignatedSender(); } // 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(to: string, subject: string, body: string, db: Database) { - let credentials = await db.getDesignatedSender() - + let credentials = await db.getDesignatedSender(); + if (!credentials) throw Error(); if (!credentials.refresh_token) throw Error(); - - if (credentials?.expires_at < new Date()) credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db) - + + if (credentials?.expires_at < new Date()) + credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db); + if (!credentials) throw Error(); - + const transporter = createTransport({ - host: "smtp.gmail.com", + 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: credentials.access_token, + type: 'OAuth2', + user: credentials.email, + clientId: GOOGLE.OAUTH_CLIENT_ID, + clientSecret: GOOGLE.OAUTH_CLIENT_SECRET, + refreshToken: credentials.refresh_token, + accessToken: credentials.access_token, }, }); transporter.sendMail({ - from: credentials.email, - to, - subject, - text: body - }) -} \ No newline at end of file + from: credentials.email, + to, + subject, + text: body, + }); +} diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index 7722f79..b489025 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -39,7 +39,7 @@ export const TokenResponse = object({ // 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()) + refresh_token: optional(string()), }); const UnixTimeSecs = pipe( diff --git a/src/routes/oauth/callback/+server.js b/src/routes/oauth/callback/+server.js index 145d181..2a12cec 100644 --- a/src/routes/oauth/callback/+server.js +++ b/src/routes/oauth/callback/+server.js @@ -55,14 +55,9 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams // Check if this session is for handling a new designated_sender, first delete sole designated_sender then complete the designated_sender if (pending.is_new_sender) { - await db.deleteDesignatedSender() - await db.initDesignatedSender(token.email) - await db.updateDesignatedSender( - token.email, - token.exp, - access_token, - refresh_token - ) + await db.deleteDesignatedSender(); + await db.initDesignatedSender(token.email); + await db.updateDesignatedSender(token.email, token.exp, access_token, refresh_token); } // Insert user as uninitialized by default diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index 67296f0..94dedcb 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -10,7 +10,9 @@ export async function GET({ locals: { db }, cookies, url: { searchParams } }) { if (user !== null) redirect(302, '/'); } - const { session_id, nonce, expiration } = await db.generatePendingSession(); + const isNewSender = Boolean(searchParams.get('new_sender')); + + const { session_id, nonce, expiration } = await db.generatePendingSession(isNewSender); cookies.set('sid', session_id, { path: '/', httpOnly: true, sameSite: 'lax', expires: expiration }); const hashedSessionId = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(session_id)); @@ -22,8 +24,8 @@ export async function GET({ locals: { db }, cookies, url: { searchParams } }) { hd: 'up.edu.ph', access_type: isNewSender ? 'offline' : 'online', response_type: 'code', - scope: OAUTH_SCOPE_STRING.concat(isNewSender ? ' https://mail.google.com/' : '' ), - prompt: isNewSender ? 'consent' : '' + scope: OAUTH_SCOPE_STRING.concat(isNewSender ? ' https://mail.google.com/' : ''), + prompt: isNewSender ? 'consent' : '', }); redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params}`); From f62eea664e5028f8e9512d0b14a7f1a2c25ff792 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Sat, 13 Jul 2024 17:13:33 +0800 Subject: [PATCH 44/68] fix: change redirect destination in oauth/callback --- src/routes/oauth/callback/+server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/oauth/callback/+server.js b/src/routes/oauth/callback/+server.js index 2a12cec..99d9641 100644 --- a/src/routes/oauth/callback/+server.js +++ b/src/routes/oauth/callback/+server.js @@ -68,5 +68,5 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams }); cookies.set('sid', sid, { path: '/', httpOnly: true, sameSite: 'lax', expires }); - redirect(302, '/dashboard/'); + redirect(302, '/'); } From d9af5422e5edb33c834a252e8962706ba62da1cc Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Sat, 13 Jul 2024 17:47:25 +0800 Subject: [PATCH 45/68] chore: run formatter --- src/lib/server/database.ts | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 67b6e07..0a7e7f3 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -1,4 +1,17 @@ -import { type InferOutput, array, bigint, boolean, date, nullable, object, parse, pick, pipe, string, transform } from 'valibot'; +import { + type InferOutput, + array, + bigint, + boolean, + date, + nullable, + object, + parse, + pick, + pipe, + string, + transform, +} from 'valibot'; import { type Loggable, timed } from '$lib/decorators'; import { fail, strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; @@ -21,10 +34,10 @@ const CreatedFacultyChoice = pick(FacultyChoice, ['choice_id', 'created_at']); const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'is_new_sender']); const DeletedValidSession = pick(Session, ['email', 'expiration']); const DesignatedSender = object({ - 'expires_at': date(), - 'email': string(), - 'access_token': string(), - 'refresh_token': nullable(string()) + expires_at: date(), + email: string(), + access_token: string(), + refresh_token: nullable(string()), }); const Drafts = array(Draft); const DraftEvents = array( @@ -41,10 +54,10 @@ const Emails = array( ), ); const EmailerCredentails = object({ - 'user_id': string(), - 'email': string(), - 'access_token': string(), - 'refresh_token': nullable(string()) + user_id: string(), + email: string(), + access_token: string(), + refresh_token: nullable(string()), }); const IncrementedDraftRound = pick(Draft, ['curr_round', 'max_rounds']); const LabQuota = pick(Lab, ['quota']); @@ -392,14 +405,14 @@ export class Database implements Loggable { const sql = this.#sql; const [first, ...rest] = await sql`SELECT * FROM drap.designated_sender`; strictEqual(rest.length, 0); - + if (typeof first === 'undefined') return; const { email } = first; - const [firstUser, ...restUsers] = + const [firstUser, ...restUsers] = await sql`SELECT user_id, email, access_token, refresh_token FROM drap.users WHERE email = ${email}`; strictEqual(restUsers.length, 0); - return typeof firstUser === 'undefined' ? null : parse(EmailerCredentails, firstUser) + return typeof firstUser === 'undefined' ? null : parse(EmailerCredentails, firstUser); } @timed async deleteDesignatedSender() { From a7949c08a1c986e9da9f925d4535f5cea25c0dbc Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Tue, 16 Jul 2024 01:32:17 +0800 Subject: [PATCH 46/68] refactor(db): rename `is_new_sender` => `has_extended_scope` --- postgres/init.sql | 11 ++++++----- src/lib/server/database.ts | 9 ++++----- src/lib/server/models/session.ts | 6 +++++- src/routes/oauth/callback/+server.js | 2 +- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index fce2714..d612d0e 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -36,7 +36,7 @@ CREATE SCHEMA drap 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), - is_new_sender BOOLEAN NOT NULL DEFAULT FALSE + has_extended_scope BOOLEAN NOT NULL DEFAULT FALSE ) CREATE TABLE sessions ( session_id UUID NOT NULL PRIMARY KEY, @@ -76,11 +76,12 @@ 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 designated_sender ( - email TEXT NOT NULL REFERENCES users (email), - access_token TEXT, - refresh_token TEXT, - expires_at TIMESTAMPTZ + email TEXT NOT NULL REFERENCES users (email) PRIMARY KEY, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expiration Expiration NOT NULL ); INSERT INTO drap.labs (lab_id, lab_name) VALUES diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 0a7e7f3..aa80a97 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -30,8 +30,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 CreatedFacultyChoice = pick(FacultyChoice, ['choice_id', 'created_at']); -const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'is_new_sender']); +const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'has_extended_scope']); const DeletedValidSession = pick(Session, ['email', 'expiration']); const DesignatedSender = object({ expires_at: date(), @@ -112,10 +111,10 @@ export class Database implements Loggable { return this.#sql.begin('ISOLATION LEVEL REPEATABLE READ', sql => fn(new Database(sql, this.#logger))); } - @timed async generatePendingSession(new_sender: boolean = false) { + @timed async generatePendingSession(hasExtendedScope = false) { const sql = this.#sql; const [first, ...rest] = - await sql`INSERT INTO drap.pendings (is_new_sender) VALUES (${new_sender}) RETURNING session_id, expiration, nonce, is_new_sender`; + 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); } @@ -123,7 +122,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, is_new_sender`; + 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); } diff --git a/src/lib/server/models/session.ts b/src/lib/server/models/session.ts index fda71f9..87a0d33 100644 --- a/src/lib/server/models/session.ts +++ b/src/lib/server/models/session.ts @@ -6,7 +6,11 @@ const CommonSchema = object({ expiration: date(), }); -export const Pending = object({ ...CommonSchema.entries, nonce: instance(Uint8Array), is_new_sender: boolean() }); +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 99d9641..d173a79 100644 --- a/src/routes/oauth/callback/+server.js +++ b/src/routes/oauth/callback/+server.js @@ -54,7 +54,7 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams strictEqual(Buffer.from(token.nonce, 'base64url').compare(pending.nonce), 0); // Check if this session is for handling a new designated_sender, first delete sole designated_sender then complete the designated_sender - if (pending.is_new_sender) { + if (pending.has_extended_scope) { await db.deleteDesignatedSender(); await db.initDesignatedSender(token.email); await db.updateDesignatedSender(token.email, token.exp, access_token, refresh_token); From b63e1dfc77cd35fd10950cc68ef563906e43cd55 Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Tue, 16 Jul 2024 01:37:10 +0800 Subject: [PATCH 47/68] refactor(db): reinstate `EXCLUDED` keyword for upserts --- src/lib/server/database.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index aa80a97..b861ca9 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -170,7 +170,7 @@ export class Database implements Loggable { ) { 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 = ${uid}, given_name = coalesce(nullif(trim(u.given_name), ''), ${given}), family_name = coalesce(nullif(trim(u.family_name), ''), ${family}), avatar = ${avatar}`; + 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; } From bfb68992b392ed7c5341bcb2c7f5119dd8fc70b9 Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Tue, 16 Jul 2024 01:37:47 +0800 Subject: [PATCH 48/68] chore: remove unused variables --- src/lib/server/database.ts | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index b861ca9..262437b 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -1,17 +1,4 @@ -import { - type InferOutput, - array, - bigint, - boolean, - date, - nullable, - object, - parse, - pick, - pipe, - string, - transform, -} from 'valibot'; +import { type InferOutput, array, bigint, boolean, date, nullable, object, parse, pick, string } from 'valibot'; import { type Loggable, timed } from '$lib/decorators'; import { fail, strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; @@ -46,12 +33,6 @@ const DraftEvents = array( }), ); const DraftMaxRounds = pick(Draft, ['max_rounds']); -const Emails = array( - pipe( - pick(User, ['email']), - transform(({ email }) => email), - ), -); const EmailerCredentails = object({ user_id: string(), email: string(), From 34ea442900bad87d3c3ff87453bceb3d66fe5308 Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Tue, 16 Jul 2024 01:41:14 +0800 Subject: [PATCH 49/68] refactor(db): rename `expires_at` => `expiration` --- src/lib/server/database.ts | 8 ++++---- src/lib/server/email/email.ts | 6 ------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 262437b..bebf02b 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -20,7 +20,7 @@ const CreatedDraft = pick(Draft, ['draft_id', 'active_period_start']); const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'has_extended_scope']); const DeletedValidSession = pick(Session, ['email', 'expiration']); const DesignatedSender = object({ - expires_at: date(), + expiration: date(), email: string(), access_token: string(), refresh_token: nullable(string()), @@ -409,14 +409,14 @@ export class Database implements Loggable { @timed async updateDesignatedSender( email: DesignatedSender['email'], - expires_at: DesignatedSender['expires_at'], + expiration: DesignatedSender['expiration'], access_token: DesignatedSender['access_token'], refresh_token: DesignatedSender['refresh_token'] = null, ) { const sql = this.#sql; const [first, ...rest] = refresh_token - ? await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token}, refresh_token = ${refresh_token} WHERE email = ${email} RETURNING *` - : await sql`UPDATE drap.designated_sender SET expires_at = ${expires_at}, access_token = ${access_token} WHERE email = ${email} RETURNING *`; + ? await sql`UPDATE drap.designated_sender SET expiration = ${expiration}, access_token = ${access_token}, refresh_token = ${refresh_token} WHERE email = ${email} RETURNING *` + : await sql`UPDATE drap.designated_sender SET expiration = ${expiration}, access_token = ${access_token} WHERE email = ${email} RETURNING *`; notEqual(typeof first, 'undefined'); strictEqual(rest.length, 0); return parse(DesignatedSender, first); diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index 096dac1..f8274fd 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -42,15 +42,9 @@ async function refreshAccessToken(refresh_token: string, email: string, db: Data // 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(to: string, subject: string, body: string, db: Database) { let credentials = await db.getDesignatedSender(); - if (!credentials) throw Error(); if (!credentials.refresh_token) throw Error(); - if (credentials?.expires_at < new Date()) - credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db); - - if (!credentials) throw Error(); - const transporter = createTransport({ host: 'smtp.gmail.com', port: 465, From 7540f0bf697a928d2f633b40f45115b414b6ee83 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 16 Jul 2024 13:05:45 +0800 Subject: [PATCH 50/68] fix: remove unused dependency --- package.json | 1 - pnpm-lock.yaml | 8 -------- 2 files changed, 9 deletions(-) diff --git a/package.json b/package.json index 65d93aa..0cd6770 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "date-fns": "^3.6.0", "itertools": "^2.3.2", "jose": "^5.6.3", - "js-base64": "^3.7.7", "just-group-by": "^2.2.0", "nodemailer": "^6.9.14", "pino": "^9.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52dea4f..ffe018f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: jose: specifier: ^5.6.3 version: 5.6.3 - js-base64: - specifier: ^3.7.7 - version: 3.7.7 just-group-by: specifier: ^2.2.0 version: 2.2.0 @@ -1465,9 +1462,6 @@ packages: jose@5.6.3: resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==} - js-base64@3.7.7: - resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3929,8 +3923,6 @@ snapshots: jose@5.6.3: {} - js-base64@3.7.7: {} - js-tokens@4.0.0: {} js-yaml@3.14.1: From 68a6140382bf9060091d1fc675a0d35311c33f90 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 16 Jul 2024 13:13:41 +0800 Subject: [PATCH 51/68] feat: move email-related models to their own file --- src/lib/server/database.ts | 16 ++-------------- src/lib/server/models/email.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 src/lib/server/models/email.ts diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index bebf02b..c99b19f 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -1,9 +1,10 @@ -import { type InferOutput, array, bigint, boolean, date, nullable, object, parse, pick, string } from 'valibot'; +import { type InferOutput, array, bigint, boolean, nullable, object, parse, pick } from 'valibot'; import { type Loggable, timed } from '$lib/decorators'; import { fail, strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; import postgres from 'postgres'; +import { DesignatedSender, EmailerCredentails } from '$lib/server/models/email'; import { FacultyChoice, FacultyChoiceEmail } from '$lib/models/faculty-choice'; import { Pending, Session } from '$lib/server/models/session'; import { Draft } from '$lib/models/draft'; @@ -19,12 +20,6 @@ const CreatedLab = pick(Lab, ['lab_id']); const CreatedDraft = pick(Draft, ['draft_id', 'active_period_start']); const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'has_extended_scope']); const DeletedValidSession = pick(Session, ['email', 'expiration']); -const DesignatedSender = object({ - expiration: date(), - email: string(), - access_token: string(), - refresh_token: nullable(string()), -}); const Drafts = array(Draft); const DraftEvents = array( object({ @@ -33,12 +28,6 @@ const DraftEvents = array( }), ); const DraftMaxRounds = pick(Draft, ['max_rounds']); -const EmailerCredentails = object({ - user_id: string(), - email: string(), - access_token: string(), - refresh_token: nullable(string()), -}); const IncrementedDraftRound = pick(Draft, ['curr_round', 'max_rounds']); const LabQuota = pick(Lab, ['quota']); const LatestDraft = pick(Draft, ['draft_id', 'curr_round', 'max_rounds', 'active_period_start']); @@ -66,7 +55,6 @@ const TaggedStudentsWithLabs = array( const UserEmails = array(pick(User, ['email'])); export type AvailableLabs = InferOutput; -export type DesignatedSender = InferOutput; export type QueriedFaculty = InferOutput; export type RegisteredLabs = InferOutput; export type StudentsWithLabPreference = InferOutput; diff --git a/src/lib/server/models/email.ts b/src/lib/server/models/email.ts new file mode 100644 index 0000000..1ecfbac --- /dev/null +++ b/src/lib/server/models/email.ts @@ -0,0 +1,19 @@ +import { type InferOutput, date, nullable, object, string } from "valibot"; + + +export const DesignatedSender = object({ + expiration: date(), + email: string(), + access_token: string(), + refresh_token: nullable(string()), +}); + +export const EmailerCredentails = object({ + user_id: string(), + email: string(), + access_token: string(), + refresh_token: nullable(string()), +}); + +export type DesignatedSender = InferOutput; +export type EmailerCredentials = InferOutput; \ No newline at end of file From f2926813b4e05f00742086b2ed5d707f3cff4b5a Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes <95967340+VeeIsForVanana@users.noreply.github.com> Date: Tue, 16 Jul 2024 13:15:07 +0800 Subject: [PATCH 52/68] fix: add missing await Co-authored-by: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> --- src/lib/server/email/email.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index f8274fd..88c2c2c 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -34,7 +34,7 @@ async function refreshAccessToken(refresh_token: string, email: string, db: Data const token = parse(pick(IdToken, ['exp']), payload); - db.updateDesignatedSender(email, token.exp, access_token); + await db.updateDesignatedSender(email, token.exp, access_token); return db.getDesignatedSender(); } From 6aca201d5f99ab9b5af497b26787efb864d10586 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes <95967340+VeeIsForVanana@users.noreply.github.com> Date: Tue, 16 Jul 2024 13:16:41 +0800 Subject: [PATCH 53/68] fix: add mising await Co-authored-by: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> --- src/lib/server/email/email.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index 88c2c2c..e5a5fe9 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -36,7 +36,7 @@ async function refreshAccessToken(refresh_token: string, email: string, db: Data await db.updateDesignatedSender(email, token.exp, access_token); - return db.getDesignatedSender(); + return await db.getDesignatedSender(); } // 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 From c728f9ad95b5515c317e531b596e4d4bf7a62252 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes <95967340+VeeIsForVanana@users.noreply.github.com> Date: Tue, 16 Jul 2024 13:37:48 +0800 Subject: [PATCH 54/68] refactor: prefer cryptic parameter name Co-authored-by: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> --- src/routes/oauth/login/+server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index 94dedcb..a7085ce 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -10,7 +10,7 @@ export async function GET({ locals: { db }, cookies, url: { searchParams } }) { if (user !== null) redirect(302, '/'); } - const isNewSender = Boolean(searchParams.get('new_sender')); + const hasExtendedScope = Boolean(searchParams.get('extended')); const { session_id, nonce, expiration } = await db.generatePendingSession(isNewSender); cookies.set('sid', session_id, { path: '/', httpOnly: true, sameSite: 'lax', expires: expiration }); From 671c680f44df365df6a4e123624d9993991b62cd Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Tue, 16 Jul 2024 22:09:10 +0800 Subject: [PATCH 55/68] fix: do not redirect an already logged-in user if they are to be a new sender --- src/routes/oauth/login/+server.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index 94dedcb..f8e9f57 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -5,13 +5,12 @@ import { redirect } from '@sveltejs/kit'; export async function GET({ locals: { db }, cookies, url: { searchParams } }) { const sid = cookies.get('sid'); + const isNewSender = Boolean(searchParams.get('new_sender')); if (typeof sid !== 'undefined') { const user = await db.getUserFromValidSession(sid); - if (user !== null) redirect(302, '/'); + if (user !== null && !isNewSender) redirect(302, '/'); } - const isNewSender = Boolean(searchParams.get('new_sender')); - const { session_id, nonce, expiration } = await db.generatePendingSession(isNewSender); cookies.set('sid', session_id, { path: '/', httpOnly: true, sameSite: 'lax', expires: expiration }); From 1f36031d5f050b2aacfee28f0e16e3e3fc600b19 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 17 Jul 2024 02:39:39 +0800 Subject: [PATCH 56/68] feat: extend allowed oauth scopes --- src/lib/server/models/oauth.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index b489025..4d38526 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -20,7 +20,10 @@ const OAUTH_SCOPES = [ 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/userinfo.email', ]; +const SENDER_OAUTH_SCOPE = OAUTH_SCOPES.concat('https://mail.google.com/') + export const OAUTH_SCOPE_STRING = OAUTH_SCOPES.join(' '); +export const SENDER_SCOPE_STRING = SENDER_OAUTH_SCOPE.join(' '); export const OAUTH_TOKEN_TYPE = 'Bearer'; /** @see https://developers.google.com/identity/protocols/oauth2#size */ From 7e88a85ae47c2e09e547c70272d2e2ada9e858e0 Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 17 Jul 2024 02:40:20 +0800 Subject: [PATCH 57/68] feat: use new oauth scope string in oauth/login --- src/routes/oauth/login/+server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index f8e9f57..20ca9c6 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -1,6 +1,6 @@ +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, url: { searchParams } }) { @@ -23,7 +23,7 @@ export async function GET({ locals: { db }, cookies, url: { searchParams } }) { hd: 'up.edu.ph', access_type: isNewSender ? 'offline' : 'online', response_type: 'code', - scope: OAUTH_SCOPE_STRING.concat(isNewSender ? ' https://mail.google.com/' : ''), + scope: isNewSender ? SENDER_SCOPE_STRING : OAUTH_SCOPE_STRING, prompt: isNewSender ? 'consent' : '', }); From caed2bb7da98ea378f6560b80884e3c10053ea0c Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 17 Jul 2024 02:45:29 +0800 Subject: [PATCH 58/68] feat: introduce check to ensure all permissions in id_token are valid --- src/lib/server/models/oauth.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index 4d38526..e9f9ea5 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -1,6 +1,7 @@ import { boolean, email, + everyItem, literal, maxLength, minLength, @@ -20,10 +21,10 @@ const OAUTH_SCOPES = [ 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/userinfo.email', ]; -const SENDER_OAUTH_SCOPE = OAUTH_SCOPES.concat('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_SCOPE.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 */ @@ -37,6 +38,7 @@ export const TokenResponse = object({ scope: pipe( string(), transform(str => str.split(' ')), + everyItem(str => SENDER_OAUTH_SCOPES.includes(str)) ), token_type: literal(OAUTH_TOKEN_TYPE), // Remaining lifetime in seconds. From 83a1d355378923a0d2145df08a33ce1152a622ad Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 17 Jul 2024 16:55:27 +0800 Subject: [PATCH 59/68] fix(db): remove non-null constraint on designated_sender --- postgres/init.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index d612d0e..57e4bf9 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -79,9 +79,9 @@ CREATE SCHEMA drap ) CREATE TABLE designated_sender ( email TEXT NOT NULL REFERENCES users (email) PRIMARY KEY, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expiration Expiration NOT NULL + access_token TEXT, + refresh_token TEXT, + expiration Expiration ); INSERT INTO drap.labs (lab_id, lab_name) VALUES From 5e8fc9f9209a934a53448c76d4cf7f6ebccf4c9b Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 17 Jul 2024 17:49:34 +0800 Subject: [PATCH 60/68] fix: remove redundant EmailerCredentials model --- src/lib/server/database.ts | 4 ++-- src/lib/server/models/email.ts | 10 +--------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index c99b19f..aab6f7d 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -4,9 +4,9 @@ import { fail, strictEqual } from 'node:assert/strict'; import type { Logger } from 'pino'; import postgres from 'postgres'; -import { DesignatedSender, EmailerCredentails } from '$lib/server/models/email'; import { FacultyChoice, FacultyChoiceEmail } from '$lib/models/faculty-choice'; import { Pending, Session } from '$lib/server/models/session'; +import { DesignatedSender } from '$lib/server/models/email'; import { Draft } from '$lib/models/draft'; import { Lab } from '$lib/models/lab'; import { StudentRank } from '$lib/models/student-rank'; @@ -380,7 +380,7 @@ export class Database implements Loggable { const [firstUser, ...restUsers] = await sql`SELECT user_id, email, access_token, refresh_token FROM drap.users WHERE email = ${email}`; strictEqual(restUsers.length, 0); - return typeof firstUser === 'undefined' ? null : parse(EmailerCredentails, firstUser); + return typeof firstUser === 'undefined' ? null : parse(DesignatedSender, firstUser); } @timed async deleteDesignatedSender() { diff --git a/src/lib/server/models/email.ts b/src/lib/server/models/email.ts index 1ecfbac..63dac05 100644 --- a/src/lib/server/models/email.ts +++ b/src/lib/server/models/email.ts @@ -8,12 +8,4 @@ export const DesignatedSender = object({ refresh_token: nullable(string()), }); -export const EmailerCredentails = object({ - user_id: string(), - email: string(), - access_token: string(), - refresh_token: nullable(string()), -}); - -export type DesignatedSender = InferOutput; -export type EmailerCredentials = InferOutput; \ No newline at end of file +export type DesignatedSender = InferOutput; \ No newline at end of file From 4a9fdb263d4d11ce00cfd7761da0525d97d389fc Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 17 Jul 2024 18:03:37 +0800 Subject: [PATCH 61/68] fix: restore credential expiration checking and refresh --- src/lib/server/email/email.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index e5a5fe9..932b426 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -45,6 +45,11 @@ export async function sendEmailTo(to: string, subject: string, body: string, db: if (!credentials) throw Error(); if (!credentials.refresh_token) throw Error(); + if (credentials.expiration < new Date()) + credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db) + + if (!credentials) throw Error(); + const transporter = createTransport({ host: 'smtp.gmail.com', port: 465, From 5686a53d54d839d4b4965bbc49871f6e823d7b2f Mon Sep 17 00:00:00 2001 From: Victor Edwin Reyes Date: Wed, 17 Jul 2024 18:04:20 +0800 Subject: [PATCH 62/68] chore: run formatter --- src/lib/server/email/email.ts | 4 ++-- src/lib/server/models/email.ts | 5 ++--- src/lib/server/models/oauth.js | 4 ++-- src/routes/oauth/login/+server.js | 1 - 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts index 932b426..62b2038 100644 --- a/src/lib/server/email/email.ts +++ b/src/lib/server/email/email.ts @@ -45,8 +45,8 @@ export async function sendEmailTo(to: string, subject: string, body: string, db: if (!credentials) throw Error(); if (!credentials.refresh_token) throw Error(); - if (credentials.expiration < new Date()) - credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db) + if (credentials.expiration < new Date()) + credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db); if (!credentials) throw Error(); diff --git a/src/lib/server/models/email.ts b/src/lib/server/models/email.ts index 63dac05..09ec3e6 100644 --- a/src/lib/server/models/email.ts +++ b/src/lib/server/models/email.ts @@ -1,5 +1,4 @@ -import { type InferOutput, date, nullable, object, string } from "valibot"; - +import { type InferOutput, date, nullable, object, string } from 'valibot'; export const DesignatedSender = object({ expiration: date(), @@ -8,4 +7,4 @@ export const DesignatedSender = object({ refresh_token: nullable(string()), }); -export type DesignatedSender = InferOutput; \ No newline at end of file +export type DesignatedSender = InferOutput; diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index e9f9ea5..8847983 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -21,7 +21,7 @@ const OAUTH_SCOPES = [ 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/userinfo.email', ]; -const SENDER_OAUTH_SCOPES = OAUTH_SCOPES.concat('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(' '); @@ -38,7 +38,7 @@ export const TokenResponse = object({ scope: pipe( string(), transform(str => str.split(' ')), - everyItem(str => SENDER_OAUTH_SCOPES.includes(str)) + everyItem(str => SENDER_OAUTH_SCOPES.includes(str)), ), token_type: literal(OAUTH_TOKEN_TYPE), // Remaining lifetime in seconds. diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index f835029..1c2fc53 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -11,7 +11,6 @@ export async function GET({ locals: { db }, cookies, url: { searchParams } }) { if (user !== null && !hasExtendedScope) redirect(302, '/'); } - const { session_id, nonce, expiration } = await db.generatePendingSession(hasExtendedScope); cookies.set('sid', session_id, { path: '/', httpOnly: true, sameSite: 'lax', expires: expiration }); From d66b4e688882a960f1e31d06413da2935afec54f Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Wed, 17 Jul 2024 21:27:13 +0800 Subject: [PATCH 63/68] refactor(db): distinguish between candidate senders from designated senders --- postgres/init.sql | 13 +++++--- src/lib/server/database.ts | 56 +++++++++++++++------------------- src/lib/server/models/email.ts | 13 ++++---- src/lib/server/models/oauth.js | 1 + 4 files changed, 41 insertions(+), 42 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index 57e4bf9..59e597c 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -36,7 +36,7 @@ CREATE SCHEMA drap 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), - has_extended_scope BOOLEAN NOT NULL DEFAULT FALSE + has_extended_scope BOOLEAN NOT NULL ) CREATE TABLE sessions ( session_id UUID NOT NULL PRIMARY KEY, @@ -77,11 +77,14 @@ CREATE SCHEMA drap FOREIGN KEY (draft_id, round, lab_id) REFERENCES faculty_choices (draft_id, round, lab_id), UNIQUE (draft_id, student_email) ) - CREATE TABLE designated_sender ( + CREATE TABLE candidate_senders ( email TEXT NOT NULL REFERENCES users (email) PRIMARY KEY, - access_token TEXT, - refresh_token TEXT, - expiration Expiration + 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 aab6f7d..b6e68ee 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -6,12 +6,11 @@ import postgres from 'postgres'; import { FacultyChoice, FacultyChoiceEmail } from '$lib/models/faculty-choice'; import { Pending, Session } from '$lib/server/models/session'; -import { DesignatedSender } from '$lib/server/models/email'; +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'; import { User } from '$lib/models/user'; -import { notEqual } from 'node:assert'; const AvailableLabs = array(pick(Lab, ['lab_id', 'lab_name'])); const BooleanResult = object({ result: boolean() }); @@ -20,6 +19,7 @@ const CreatedLab = pick(Lab, ['lab_id']); const CreatedDraft = pick(Draft, ['draft_id', 'active_period_start']); const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'has_extended_scope']); const DeletedValidSession = pick(Session, ['email', 'expiration']); +const DesignatedSenderCredentials = pick(CandidateSender, ['email', 'access_token', 'refresh_token']); const Drafts = array(Draft); const DraftEvents = array( object({ @@ -80,7 +80,7 @@ export class Database implements Loggable { return this.#sql.begin('ISOLATION LEVEL REPEATABLE READ', sql => fn(new Database(sql, this.#logger))); } - @timed async generatePendingSession(hasExtendedScope = false) { + @timed async generatePendingSession(hasExtendedScope: boolean) { const sql = this.#sql; const [first, ...rest] = await sql`INSERT INTO drap.pendings (has_extended_scope) VALUES (${hasExtendedScope}) RETURNING session_id, expiration, nonce, has_extended_scope`; @@ -369,45 +369,39 @@ export class Database implements Loggable { return typeof first === 'undefined' ? null : parse(QueriedStudentRank, first); } - @timed async getDesignatedSender() { + /** + * 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 * FROM drap.designated_sender`; + const [first, ...rest] = + await sql`SELECT email, access_token, refresh_token FROM drap.designated_sender JOIN drap.candidate_senders USING (email) JOIN drap.users (email) WHERE NOW() < expiration - INTERVAL '5 minutes' AND user_id IS NOT NULL AND is_admin AND lab_id IS NULL`; strictEqual(rest.length, 0); - - if (typeof first === 'undefined') return; - - const { email } = first; - const [firstUser, ...restUsers] = - await sql`SELECT user_id, email, access_token, refresh_token FROM drap.users WHERE email = ${email}`; - strictEqual(restUsers.length, 0); - return typeof firstUser === 'undefined' ? null : parse(DesignatedSender, firstUser); + return typeof first === 'undefined' ? null : parse(DesignatedSenderCredentials, first); } - @timed async deleteDesignatedSender() { + @timed async upsertCandidateSender( + email: CandidateSender['email'], + expiration: CandidateSender['expiration'], + accessToken: CandidateSender['access_token'], + refreshToken: CandidateSender['refresh_token'], + ) { const sql = this.#sql; - const count = await sql`DELETE FROM drap.designated_sender`; - return count; + const { count } = + await 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 RETURNING email`; + strictEqual(count, 1); } - @timed async initDesignatedSender(email: DesignatedSender['email']) { + @timed async clearDesignatedSenders() { const sql = this.#sql; - const count = await sql`INSERT INTO drap.designated_sender (email) VALUES (${email})`; - return count; + await sql`TRUNCATE drap.designated_sender`; } - @timed async updateDesignatedSender( - email: DesignatedSender['email'], - expiration: DesignatedSender['expiration'], - access_token: DesignatedSender['access_token'], - refresh_token: DesignatedSender['refresh_token'] = null, - ) { + @timed async insertDesignatedSender(email: CandidateSender['email']) { const sql = this.#sql; - const [first, ...rest] = refresh_token - ? await sql`UPDATE drap.designated_sender SET expiration = ${expiration}, access_token = ${access_token}, refresh_token = ${refresh_token} WHERE email = ${email} RETURNING *` - : await sql`UPDATE drap.designated_sender SET expiration = ${expiration}, access_token = ${access_token} WHERE email = ${email} RETURNING *`; - notEqual(typeof first, 'undefined'); - strictEqual(rest.length, 0); - return parse(DesignatedSender, first); + const { count } = await sql`INSERT INTO drap.designated_sender (email) VALUES (${email})`; + strictEqual(count, 1); } /** diff --git a/src/lib/server/models/email.ts b/src/lib/server/models/email.ts index 09ec3e6..58896a9 100644 --- a/src/lib/server/models/email.ts +++ b/src/lib/server/models/email.ts @@ -1,10 +1,11 @@ -import { type InferOutput, date, nullable, object, string } from 'valibot'; +import { type InferOutput, date, maxLength, object, pipe, string } from 'valibot'; +import { User } from '$lib/models/user'; -export const DesignatedSender = object({ +export const CandidateSender = object({ expiration: date(), - email: string(), - access_token: string(), - refresh_token: nullable(string()), + email: User.entries.email, + access_token: pipe(string(), maxLength(2048)), + refresh_token: pipe(string(), maxLength(512)), }); -export type DesignatedSender = InferOutput; +export type CandidateSender = InferOutput; diff --git a/src/lib/server/models/oauth.js b/src/lib/server/models/oauth.js index 8847983..4ce02c1 100644 --- a/src/lib/server/models/oauth.js +++ b/src/lib/server/models/oauth.js @@ -20,6 +20,7 @@ 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/'); From 8e27241e0dcfed34d35c86748ef5ddcf025c76e6 Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Wed, 17 Jul 2024 21:48:56 +0800 Subject: [PATCH 64/68] refactor(oauth): only check for the existence of the `extended` search parameter --- src/routes/oauth/login/+server.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/routes/oauth/login/+server.js b/src/routes/oauth/login/+server.js index 1c2fc53..8a12132 100644 --- a/src/routes/oauth/login/+server.js +++ b/src/routes/oauth/login/+server.js @@ -5,15 +5,16 @@ import { redirect } from '@sveltejs/kit'; export async function GET({ locals: { db }, cookies, url: { searchParams } }) { const sid = cookies.get('sid'); - const hasExtendedScope = Boolean(searchParams.get('extended')); + const hasExtendedScope = searchParams.has('extended'); if (typeof sid !== 'undefined') { const user = await db.getUserFromValidSession(sid); - if (user !== null && !hasExtendedScope) redirect(302, '/'); + if (user !== null) redirect(302, '/'); } 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'), @@ -21,11 +22,17 @@ export async function GET({ locals: { db }, cookies, url: { searchParams } }) { redirect_uri: GOOGLE.OAUTH_REDIRECT_URI, nonce: Buffer.from(nonce).toString('base64url'), hd: 'up.edu.ph', - access_type: hasExtendedScope ? 'offline' : 'online', response_type: 'code', - scope: hasExtendedScope ? SENDER_SCOPE_STRING : OAUTH_SCOPE_STRING, - prompt: hasExtendedScope ? 'consent' : '', }); + 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}`); } From b0c42cc22c6d7b2e702ad0681c989df703a19f87 Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Wed, 17 Jul 2024 22:05:22 +0800 Subject: [PATCH 65/68] refactor(oauth): assert more properties before reassigning the designated sender --- src/lib/server/database.ts | 8 +++++--- src/routes/oauth/callback/+server.js | 22 ++++++++++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index b6e68ee..3d9082f 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -52,6 +52,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; @@ -138,9 +139,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( diff --git a/src/routes/oauth/callback/+server.js b/src/routes/oauth/callback/+server.js index d173a79..3183d25 100644 --- a/src/routes/oauth/callback/+server.js +++ b/src/routes/oauth/callback/+server.js @@ -53,17 +53,23 @@ export async function GET({ fetch, locals: { db }, cookies, url: { searchParams ok(token.email_verified); strictEqual(Buffer.from(token.nonce, 'base64url').compare(pending.nonce), 0); - // Check if this session is for handling a new designated_sender, first delete sole designated_sender then complete the designated_sender - if (pending.has_extended_scope) { - await db.deleteDesignatedSender(); - await db.initDesignatedSender(token.email); - await db.updateDesignatedSender(token.email, token.exp, access_token, refresh_token); - } - // 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; }); From 05d7740c094c8c436c7f6b7567858f861f56a768 Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Wed, 17 Jul 2024 22:08:05 +0800 Subject: [PATCH 66/68] refactor: remove one level of directory indirection --- src/lib/server/email/email.ts | 73 ----------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 src/lib/server/email/email.ts diff --git a/src/lib/server/email/email.ts b/src/lib/server/email/email.ts deleted file mode 100644 index 62b2038..0000000 --- a/src/lib/server/email/email.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { IdToken, TokenResponse } from '$lib/server/models/oauth'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; -import { parse, pick } from 'valibot'; - -import type { Database } from '$lib/server/database'; -import GOOGLE from '$lib/server/env/google'; -import { createTransport } from 'nodemailer'; - -// this function refreshes the access token and updates the db accordingly -async function refreshAccessToken(refresh_token: string, email: string, db: Database) { - const fetchJwks = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs')); - - const body = new URLSearchParams({ - refresh_token: refresh_token, - client_id: GOOGLE.OAUTH_CLIENT_ID, - client_secret: GOOGLE.OAUTH_CLIENT_SECRET, - grant_type: 'refresh_token', - }); - - const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }); - - 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.updateDesignatedSender(email, token.exp, access_token); - - return await db.getDesignatedSender(); -} - -// 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(to: string, subject: string, body: string, db: Database) { - let credentials = await db.getDesignatedSender(); - if (!credentials) throw Error(); - if (!credentials.refresh_token) throw Error(); - - if (credentials.expiration < new Date()) - credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db); - - if (!credentials) throw Error(); - - const transporter = 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: credentials.access_token, - }, - }); - - transporter.sendMail({ - from: credentials.email, - to, - subject, - text: body, - }); -} From efbef7255fd4716e189cf916c3249a8639fd2bc1 Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Wed, 17 Jul 2024 22:10:55 +0800 Subject: [PATCH 67/68] refactor(jwks): move JSON Web Key Set fetcher to own module --- src/lib/server/email.ts | 71 ++++++++++++++++++++++++++++ src/lib/server/jwks.js | 2 + src/routes/oauth/callback/+server.js | 5 +- 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/email.ts create mode 100644 src/lib/server/jwks.js diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts new file mode 100644 index 0000000..77e2429 --- /dev/null +++ b/src/lib/server/email.ts @@ -0,0 +1,71 @@ +import { IdToken, TokenResponse } from '$lib/server/models/oauth'; +import { parse, pick } from 'valibot'; +import { fetchJwks } from '$lib/server/jwks'; +import { jwtVerify } from 'jose'; + +import type { Database } from '$lib/server/database'; +import GOOGLE from '$lib/server/env/google'; +import { createTransport } from 'nodemailer'; + +// this function refreshes the access token and updates the db accordingly +async function refreshAccessToken(refreshToken: string, email: string, db: Database) { + const body = new URLSearchParams({ + refresh_token: refreshToken, + client_id: GOOGLE.OAUTH_CLIENT_ID, + client_secret: GOOGLE.OAUTH_CLIENT_SECRET, + grant_type: 'refresh_token', + }); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + const json = await res.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, + }); + + const token = parse(pick(IdToken, ['exp']), payload); + + await db.updateDesignatedSender(email, token.exp, access_token); + + return await db.getDesignatedSender(); +} + +// 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(to: string, subject: string, body: string, db: Database) { + let credentials = await db.getDesignatedSender(); + if (!credentials) throw Error(); + if (!credentials.refresh_token) throw Error(); + + if (credentials.expiration < new Date()) + credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db); + + if (!credentials) throw Error(); + + const transporter = 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: credentials.access_token, + }, + }); + + transporter.sendMail({ + from: credentials.email, + to, + subject, + text: body, + }); +} 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/routes/oauth/callback/+server.js b/src/routes/oauth/callback/+server.js index 3183d25..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/'); From 535c9a200c543c25a8f5d1b4f741ebed2cb7b741 Mon Sep 17 00:00:00 2001 From: Basti Ortiz <39114273+BastiDood@users.noreply.github.com> Date: Wed, 17 Jul 2024 23:55:29 +0800 Subject: [PATCH 68/68] refactor(email): simplify control flow for email sender --- src/lib/server/database.ts | 14 ++++++---- src/lib/server/email.ts | 57 +++++++++++++++----------------------- 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 3d9082f..90369e6 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -19,7 +19,6 @@ const CreatedLab = pick(Lab, ['lab_id']); const CreatedDraft = pick(Draft, ['draft_id', 'active_period_start']); const DeletedPendingSession = pick(Pending, ['nonce', 'expiration', 'has_extended_scope']); const DeletedValidSession = pick(Session, ['email', 'expiration']); -const DesignatedSenderCredentials = pick(CandidateSender, ['email', 'access_token', 'refresh_token']); const Drafts = array(Draft); const DraftEvents = array( object({ @@ -378,20 +377,23 @@ export class Database implements Loggable { @timed async getDesignatedSenderCredentials() { const sql = this.#sql; const [first, ...rest] = - await sql`SELECT email, access_token, refresh_token FROM drap.designated_sender JOIN drap.candidate_senders USING (email) JOIN drap.users (email) WHERE NOW() < expiration - INTERVAL '5 minutes' AND user_id IS NOT NULL AND is_admin AND lab_id IS NULL`; + 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(DesignatedSenderCredentials, first); + 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'], + refreshToken?: CandidateSender['refresh_token'], ) { const sql = this.#sql; - const { count } = - await 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 RETURNING email`; + 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); } diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts index 77e2429..c27ad1d 100644 --- a/src/lib/server/email.ts +++ b/src/lib/server/email.ts @@ -1,54 +1,43 @@ 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'; -import { createTransport } from 'nodemailer'; - -// this function refreshes the access token and updates the db accordingly -async function refreshAccessToken(refreshToken: string, email: string, db: Database) { - const body = new URLSearchParams({ - refresh_token: refreshToken, - client_id: GOOGLE.OAUTH_CLIENT_ID, - client_secret: GOOGLE.OAUTH_CLIENT_SECRET, - grant_type: 'refresh_token', - }); +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, + 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, refresh_token } = parse(TokenResponse, 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.updateDesignatedSender(email, token.exp, access_token); - - return await db.getDesignatedSender(); + 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(to: string, subject: string, body: string, db: Database) { - let credentials = await db.getDesignatedSender(); - if (!credentials) throw Error(); - if (!credentials.refresh_token) throw Error(); - - if (credentials.expiration < new Date()) - credentials = await refreshAccessToken(credentials.refresh_token, credentials.email, db); - - if (!credentials) throw Error(); +export async function sendEmailTo(db: Database, to: string, subject: string, text: string) { + const credentials = await db.getDesignatedSenderCredentials(); + if (credentials === null) return false; - const transporter = createTransport({ + const sendMail = await createTransport({ host: 'smtp.gmail.com', port: 465, secure: true, @@ -58,14 +47,12 @@ export async function sendEmailTo(to: string, subject: string, body: string, db: clientId: GOOGLE.OAUTH_CLIENT_ID, clientSecret: GOOGLE.OAUTH_CLIENT_SECRET, refreshToken: credentials.refresh_token, - accessToken: credentials.access_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 }); - transporter.sendMail({ - from: credentials.email, - to, - subject, - text: body, - }); + db.logger.info({ sendMail }); + return true; }