From 81e2e53cc55103490db2b06bda259aba1928b1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Wed, 2 Jul 2025 23:56:11 +0200 Subject: [PATCH] lib sources restructuring --- src/lib/google/auth/client.ts | 121 ++++++++++++++++++ src/lib/google/auth/server.ts | 57 +++++++++ src/lib/google/gmail/index.ts | 90 +++++++++++++ src/lib/google/index.ts | 18 +++ src/lib/google/sheets/index.ts | 77 +++++++++++ src/lib/helpers/event.ts | 13 -- src/lib/types/quoted-printable.d.ts | 5 + src/routes/api/auth/refresh/+server.ts | 4 +- .../api/sheets/[sheetId]/data/+server.ts | 4 +- src/routes/api/sheets/recent/+server.ts | 6 +- src/routes/auth/google/+server.ts | 4 +- src/routes/auth/google/callback/+server.ts | 4 +- src/routes/private/api/gmail/+server.ts | 79 ------------ .../api/google/auth/refresh/+server.ts | 4 +- .../api/google/auth/userinfo/+server.ts | 4 +- .../google/sheets/[sheetId]/data/+server.ts | 4 +- .../api/google/sheets/recent/+server.ts | 6 +- .../private/events/event/new/+page.svelte | 4 +- .../new/components/GoogleSheetsStep.svelte | 2 +- 19 files changed, 391 insertions(+), 115 deletions(-) create mode 100644 src/lib/google/auth/client.ts create mode 100644 src/lib/google/auth/server.ts create mode 100644 src/lib/google/gmail/index.ts create mode 100644 src/lib/google/index.ts create mode 100644 src/lib/google/sheets/index.ts delete mode 100644 src/lib/helpers/event.ts create mode 100644 src/lib/types/quoted-printable.d.ts delete mode 100644 src/routes/private/api/gmail/+server.ts diff --git a/src/lib/google/auth/client.ts b/src/lib/google/auth/client.ts new file mode 100644 index 0000000..6c9e654 --- /dev/null +++ b/src/lib/google/auth/client.ts @@ -0,0 +1,121 @@ +import { browser } from '$app/environment'; + +// Client-side only functions +export const scopes = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/spreadsheets.readonly' +]; + +/** + * Initialize Google Auth (placeholder for client-side) + */ +export async function initGoogleAuth(): Promise { + if (!browser) return; + // Google Auth initialization is handled by the OAuth flow + // No initialization needed for our server-side approach +} + +/** + * Get the Google Auth URL + * @returns URL for Google OAuth + */ +export function getAuthUrl(): string { + if (!browser) return ''; + // This should be obtained from the server + return '/auth/google'; +} + +/** + * Check if an access token is valid + * @param accessToken - Google access token to validate + * @returns True if the token is valid + */ +export async function isTokenValid(accessToken: string): Promise { + if (!browser) return false; + + try { + const response = await fetch(`https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${accessToken}`); + const data = await response.json(); + + if (response.ok && data.expires_in && data.expires_in > 0) { + return true; + } + return false; + } catch (error) { + console.error('Error validating token:', error); + return false; + } +} + +/** + * Refresh an access token using the refresh token + * @param refreshToken - Google refresh token + * @returns New access token or null if failed + */ +export async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await fetch('/private/api/google/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refreshToken }) + }); + + if (response.ok) { + const data = await response.json(); + return data.accessToken; + } + return null; + } catch (error) { + console.error('Error refreshing token:', error); + return null; + } +} + +/** + * Get Google user information + * @param accessToken - Google access token + * @returns User info including email, name, and picture + */ +export async function getUserInfo(accessToken: string): Promise<{ email: string; name: string; picture: string } | null> { + try { + const response = await fetch('/private/api/google/auth/userinfo', { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Error fetching user info:', error); + return null; + } +} + +/** + * Revoke a Google access token + * @param accessToken - Google access token to revoke + * @returns True if revocation was successful + */ +export async function revokeToken(accessToken: string): Promise { + try { + const response = await fetch('/private/api/google/auth/revoke', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ accessToken }) + }); + + return response.ok; + } catch (error) { + console.error('Error revoking token:', error); + return false; + } +} diff --git a/src/lib/google/auth/server.ts b/src/lib/google/auth/server.ts new file mode 100644 index 0000000..6e937ae --- /dev/null +++ b/src/lib/google/auth/server.ts @@ -0,0 +1,57 @@ +import { google } from 'googleapis'; +import { env } from '$env/dynamic/private'; + +// Define OAuth scopes for the Google APIs we need to access +export const scopes = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/spreadsheets.readonly' +]; + +/** + * Create a new OAuth2 client instance + * @returns Google OAuth2 client + */ +export function getOAuthClient() { + return new google.auth.OAuth2( + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + env.GOOGLE_REDIRECT_URI + ); +} + +/** + * Create a authentication URL for OAuth flow + * @returns Auth URL for Google OAuth + */ +export function createAuthUrl() { + return getOAuthClient().generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: scopes, + redirect_uri: env.GOOGLE_REDIRECT_URI + }); +} + +/** + * Exchange the authorization code for access and refresh tokens + * @param code - Authorization code from OAuth callback + * @returns Access and refresh tokens + */ +export async function exchangeCodeForTokens(code: string) { + const { tokens } = await getOAuthClient().getToken(code); + if (!tokens.refresh_token) throw new Error('No refresh_token returned'); + return tokens; +} + +/** + * Get an authenticated client using a refresh token + * @param refreshToken - Refresh token for authentication + * @returns Authenticated OAuth2 client + */ +export function getAuthenticatedClient(refreshToken: string) { + const oauth = getOAuthClient(); + oauth.setCredentials({ refresh_token: refreshToken }); + return oauth; +} diff --git a/src/lib/google/gmail/index.ts b/src/lib/google/gmail/index.ts new file mode 100644 index 0000000..d281afc --- /dev/null +++ b/src/lib/google/gmail/index.ts @@ -0,0 +1,90 @@ +import { google } from 'googleapis'; +import quotedPrintable from 'quoted-printable'; +import { getAuthenticatedClient } from '../auth/server.js'; + +/** + * Create an HTML email template + * @param text - Email body text + * @returns HTML email template + */ +export function createEmailTemplate(text: string): string { + return ` + + + + + +
+

${text}

+ QR Code +
+
+
+
+
+
+
+
+

This email has been generated with the help of ScanWave

+
+
+ +`; +} + +/** + * Send an email through Gmail + * @param refreshToken - Google refresh token + * @param params - Email parameters (to, subject, text, qr_code) + */ +export async function sendGmail( + refreshToken: string, + { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string } +) { + const oauth = getAuthenticatedClient(refreshToken); + const gmail = google.gmail({ version: 'v1', auth: oauth }); + + const message_html = createEmailTemplate(text); + const boundary = 'BOUNDARY'; + const nl = '\r\n'; + + // Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode + const htmlBuffer = Buffer.from(message_html, 'utf8'); + const htmlLatin1 = htmlBuffer.toString('latin1'); + const htmlQP = quotedPrintable.encode(htmlLatin1); + const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl); + + const rawParts = [ + 'MIME-Version: 1.0', + `To: ${to}`, + `Subject: ${subject}`, + `Content-Type: multipart/related; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/html; charset="UTF-8"', + 'Content-Transfer-Encoding: quoted-printable', + '', + htmlQP, + '', + `--${boundary}`, + 'Content-Type: image/png', + 'Content-Transfer-Encoding: base64', + 'Content-ID: ', + 'Content-Disposition: inline; filename="qr.png"', + '', + qrLines, + '', + `--${boundary}--`, + '' + ]; + + const rawMessage = rawParts.join(nl); + const raw = Buffer.from(rawMessage).toString('base64url'); + + await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw } + }); +} diff --git a/src/lib/google/index.ts b/src/lib/google/index.ts new file mode 100644 index 0000000..c1e5bd5 --- /dev/null +++ b/src/lib/google/index.ts @@ -0,0 +1,18 @@ +/** + * Google API integration module + * + * This module provides utilities for interacting with Google APIs: + * - Authentication (server and client-side) + * - Sheets API + * - Gmail API + */ + +// Re-export auth utilities +export * from './auth/client.js'; +export * as authServer from './auth/server.js'; + +// Re-export sheets utilities +export * as sheets from './sheets/index.js'; + +// Re-export Gmail utilities +export * as gmail from './gmail/index.js'; diff --git a/src/lib/google/sheets/index.ts b/src/lib/google/sheets/index.ts new file mode 100644 index 0000000..8eaf173 --- /dev/null +++ b/src/lib/google/sheets/index.ts @@ -0,0 +1,77 @@ +import { google } from 'googleapis'; +import { getAuthenticatedClient } from '../auth/server.js'; + +export interface GoogleSheet { + id: string; + name: string; + modifiedTime: string; + webViewLink: string; +} + +export interface SheetData { + values: string[][]; +} + +/** + * Get a list of recent Google Sheets + * @param refreshToken - Google refresh token + * @param limit - Maximum number of sheets to return + * @returns List of Google Sheets + */ +export async function getRecentSpreadsheets(refreshToken: string, limit: number = 10): Promise { + const oauth = getAuthenticatedClient(refreshToken); + const drive = google.drive({ version: 'v3', auth: oauth }); + + const response = await drive.files.list({ + q: "mimeType='application/vnd.google-apps.spreadsheet'", + orderBy: 'modifiedTime desc', + pageSize: limit, + fields: 'files(id,name,modifiedTime,webViewLink)' + }); + + return response.data.files?.map(file => ({ + id: file.id!, + name: file.name!, + modifiedTime: file.modifiedTime!, + webViewLink: file.webViewLink! + })) || []; +} + +/** + * Get data from a Google Sheet + * @param refreshToken - Google refresh token + * @param spreadsheetId - ID of the spreadsheet + * @param range - Cell range to retrieve (default: A1:Z10) + * @returns Sheet data as a 2D array + */ +export async function getSpreadsheetData(refreshToken: string, spreadsheetId: string, range: string = 'A1:Z10'): Promise { + const oauth = getAuthenticatedClient(refreshToken); + const sheets = google.sheets({ version: 'v4', auth: oauth }); + + const response = await sheets.spreadsheets.values.get({ + spreadsheetId, + range + }); + + return { + values: response.data.values || [] + }; +} + +/** + * Get metadata about a Google Sheet + * @param refreshToken - Google refresh token + * @param spreadsheetId - ID of the spreadsheet + * @returns Spreadsheet metadata + */ +export async function getSpreadsheetInfo(refreshToken: string, spreadsheetId: string) { + const oauth = getAuthenticatedClient(refreshToken); + const sheets = google.sheets({ version: 'v4', auth: oauth }); + + const response = await sheets.spreadsheets.get({ + spreadsheetId, + fields: 'properties.title,sheets.properties(title,sheetId)' + }); + + return response.data; +} diff --git a/src/lib/helpers/event.ts b/src/lib/helpers/event.ts deleted file mode 100644 index 07fc008..0000000 --- a/src/lib/helpers/event.ts +++ /dev/null @@ -1,13 +0,0 @@ -export async function load({ data, url }) { - const event_id = url.searchParams.get('id'); - const { data: event_data, error: eventError } = await locals.supabase - .from('events') - .select('*') - .eq('id', event_id) - .single() - const { data: participants, error: participantsError } = await locals.supabase - .from('participants') - .select('*, scanned_by:profiles (id, display_name)') - .eq('event', event_id) - return {event_data, participants}; -} \ No newline at end of file diff --git a/src/lib/types/quoted-printable.d.ts b/src/lib/types/quoted-printable.d.ts new file mode 100644 index 0000000..a8b40d2 --- /dev/null +++ b/src/lib/types/quoted-printable.d.ts @@ -0,0 +1,5 @@ +declare module 'quoted-printable' { + export function encode(text: string): string; + export function decode(text: string): string; + export default { encode, decode }; +} diff --git a/src/routes/api/auth/refresh/+server.ts b/src/routes/api/auth/refresh/+server.ts index e6561ee..83eecbb 100644 --- a/src/routes/api/auth/refresh/+server.ts +++ b/src/routes/api/auth/refresh/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getOAuthClient } from '$lib/google-server.js'; +import { authServer } from '$lib/google/index.js'; export const POST: RequestHandler = async ({ request }) => { try { @@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'Refresh token is required' }, { status: 400 }); } - const oauth = getOAuthClient(); + const oauth = authServer.getOAuthClient(); oauth.setCredentials({ refresh_token: refreshToken }); const { credentials } = await oauth.refreshAccessToken(); diff --git a/src/routes/api/sheets/[sheetId]/data/+server.ts b/src/routes/api/sheets/[sheetId]/data/+server.ts index 263e099..e87c1ff 100644 --- a/src/routes/api/sheets/[sheetId]/data/+server.ts +++ b/src/routes/api/sheets/[sheetId]/data/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getSpreadsheetData } from '$lib/sheets.js'; +import { sheets } from '$lib/google/index.js'; export const GET: RequestHandler = async ({ params, request }) => { try { @@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ params, request }) => { } const refreshToken = authHeader.slice(7); - const sheetData = await getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); + const sheetData = await sheets.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); return json(sheetData); } catch (error) { diff --git a/src/routes/api/sheets/recent/+server.ts b/src/routes/api/sheets/recent/+server.ts index d9b94a2..ed01813 100644 --- a/src/routes/api/sheets/recent/+server.ts +++ b/src/routes/api/sheets/recent/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getRecentSpreadsheets } from '$lib/sheets.js'; +import { sheets } from '$lib/google/index.js'; export const GET: RequestHandler = async ({ request }) => { try { @@ -10,9 +10,9 @@ export const GET: RequestHandler = async ({ request }) => { } const refreshToken = authHeader.slice(7); - const sheets = await getRecentSpreadsheets(refreshToken, 20); + const spreadsheets = await sheets.getRecentSpreadsheets(refreshToken, 20); - return json(sheets); + return json(spreadsheets); } catch (error) { console.error('Error fetching recent spreadsheets:', error); return json({ error: 'Failed to fetch spreadsheets' }, { status: 500 }); diff --git a/src/routes/auth/google/+server.ts b/src/routes/auth/google/+server.ts index d407c22..49205f7 100644 --- a/src/routes/auth/google/+server.ts +++ b/src/routes/auth/google/+server.ts @@ -1,8 +1,8 @@ import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { createAuthUrl } from '$lib/google-server.js'; +import { authServer } from '$lib/google/index.js'; export const GET: RequestHandler = () => { - const authUrl = createAuthUrl(); + const authUrl = authServer.createAuthUrl(); throw redirect(302, authUrl); }; diff --git a/src/routes/auth/google/callback/+server.ts b/src/routes/auth/google/callback/+server.ts index 840490c..d369a58 100644 --- a/src/routes/auth/google/callback/+server.ts +++ b/src/routes/auth/google/callback/+server.ts @@ -1,6 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getOAuthClient } from '$lib/google-server.js'; +import { authServer } from '$lib/google/index.js'; export const GET: RequestHandler = async ({ url }) => { try { @@ -17,7 +17,7 @@ export const GET: RequestHandler = async ({ url }) => { } // Exchange code for tokens - const oauth = getOAuthClient(); + const oauth = authServer.getOAuthClient(); const { tokens } = await oauth.getToken(code); if (!tokens.refresh_token || !tokens.access_token) { diff --git a/src/routes/private/api/gmail/+server.ts b/src/routes/private/api/gmail/+server.ts deleted file mode 100644 index 62b73e8..0000000 --- a/src/routes/private/api/gmail/+server.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { RequestHandler } from './$types'; -import { json, redirect } from '@sveltejs/kit'; -import { - createAuthUrl, - exchangeCodeForTokens, - getOAuthClient -} from '$lib/google'; -import { sendGmail } from '$lib/gmail'; - -/* ───────────── GET ───────────── */ -export const GET: RequestHandler = async ({ url }) => { - /* 1. /private/api/gmail?action=auth → 302 to Google */ - if (url.searchParams.get('action') === 'auth') { - throw redirect(302, createAuthUrl()); - } - - /* 2. Google callback /private/api/gmail?code=XXXX */ - const code = url.searchParams.get('code'); - if (code) { - try { - const refreshToken = await exchangeCodeForTokens(code); - - const html = ` - `; - return new Response(html, { headers: { 'Content-Type': 'text/html' } }); - } catch (err) { - return new Response((err as Error).message, { status: 500 }); - } - } - - return new Response('Bad request', { status: 400 }); -}; - -/* ───────────── POST ───────────── */ -export const POST: RequestHandler = async ({ request }) => { - const { action, refreshToken, to, subject, text, qr_code } = await request.json(); - - /* send e-mail */ - if (action === 'send') { - if (!refreshToken) return new Response('Missing token', { status: 401 }); - try { - await sendGmail(refreshToken, { to, subject, text, qr_code }); - return json({ ok: true }); - } catch (err) { - return new Response((err as Error).message, { status: 500 }); - } - } - - /* revoke token */ - if (action === 'revoke') { - if (!refreshToken) return new Response('Missing token', { status: 401 }); - try { - await getOAuthClient().revokeToken(refreshToken); - return json({ ok: true }); - } catch (err) { - return new Response((err as Error).message, { status: 500 }); - } - } - - /* validate token */ - if (action === 'validate') { - if (!refreshToken) { - return json({ valid: false }); - } - try { - const oAuth2Client = getOAuthClient(); - oAuth2Client.setCredentials({ refresh_token: refreshToken }); - await oAuth2Client.getAccessToken(); // This will throw if invalid - return json({ valid: true }); - } catch (err) { - return json({ valid: false, error: (err as Error).message }); - } - } - - return new Response('Bad request', { status: 400 }); -}; diff --git a/src/routes/private/api/google/auth/refresh/+server.ts b/src/routes/private/api/google/auth/refresh/+server.ts index e6561ee..83eecbb 100644 --- a/src/routes/private/api/google/auth/refresh/+server.ts +++ b/src/routes/private/api/google/auth/refresh/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getOAuthClient } from '$lib/google-server.js'; +import { authServer } from '$lib/google/index.js'; export const POST: RequestHandler = async ({ request }) => { try { @@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'Refresh token is required' }, { status: 400 }); } - const oauth = getOAuthClient(); + const oauth = authServer.getOAuthClient(); oauth.setCredentials({ refresh_token: refreshToken }); const { credentials } = await oauth.refreshAccessToken(); diff --git a/src/routes/private/api/google/auth/userinfo/+server.ts b/src/routes/private/api/google/auth/userinfo/+server.ts index cb98710..ac76ea0 100644 --- a/src/routes/private/api/google/auth/userinfo/+server.ts +++ b/src/routes/private/api/google/auth/userinfo/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getOAuthClient } from '$lib/google-server.js'; +import { authServer } from '$lib/google/index.js'; import { google } from 'googleapis'; export const GET: RequestHandler = async ({ request }) => { @@ -14,7 +14,7 @@ export const GET: RequestHandler = async ({ request }) => { const accessToken = authHeader.slice(7); // Create OAuth client with the token - const oauth = getOAuthClient(); + const oauth = authServer.getOAuthClient(); oauth.setCredentials({ access_token: accessToken }); // Call the userinfo endpoint to get user details diff --git a/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts index 263e099..e87c1ff 100644 --- a/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts +++ b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getSpreadsheetData } from '$lib/sheets.js'; +import { sheets } from '$lib/google/index.js'; export const GET: RequestHandler = async ({ params, request }) => { try { @@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ params, request }) => { } const refreshToken = authHeader.slice(7); - const sheetData = await getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); + const sheetData = await sheets.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); return json(sheetData); } catch (error) { diff --git a/src/routes/private/api/google/sheets/recent/+server.ts b/src/routes/private/api/google/sheets/recent/+server.ts index d9b94a2..ed01813 100644 --- a/src/routes/private/api/google/sheets/recent/+server.ts +++ b/src/routes/private/api/google/sheets/recent/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getRecentSpreadsheets } from '$lib/sheets.js'; +import { sheets } from '$lib/google/index.js'; export const GET: RequestHandler = async ({ request }) => { try { @@ -10,9 +10,9 @@ export const GET: RequestHandler = async ({ request }) => { } const refreshToken = authHeader.slice(7); - const sheets = await getRecentSpreadsheets(refreshToken, 20); + const spreadsheets = await sheets.getRecentSpreadsheets(refreshToken, 20); - return json(sheets); + return json(spreadsheets); } catch (error) { console.error('Error fetching recent spreadsheets:', error); return json({ error: 'Failed to fetch spreadsheets' }, { status: 500 }); diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte index b3868bb..3e87601 100644 --- a/src/routes/private/events/event/new/+page.svelte +++ b/src/routes/private/events/event/new/+page.svelte @@ -1,7 +1,7 @@