From 05207189cf166badc18cb0bc371e2fafa3c4186e Mon Sep 17 00:00:00 2001 From: arc-alex Date: Wed, 15 Nov 2023 09:06:03 +0100 Subject: [PATCH] add google sheet functions --- .../src/controllers/ontimeController.ts | 59 +++++ apps/server/src/routes/ontimeRouter.ts | 16 ++ apps/server/src/utils/sheetsAuth.ts | 250 ++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 apps/server/src/utils/sheetsAuth.ts diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index d481e245a..089298663 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -17,6 +17,8 @@ import { deepmerge } from 'ontime-utils'; import { runtimeCacheStore } from '../stores/cachingStore.js'; import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js'; +import { Sheet } from '../utils/sheetsAuth.js'; + // Create controller for GET request to '/ontime/poll' // Returns data for current state export const poll = async (req, res) => { @@ -390,6 +392,63 @@ export async function previewExcel(req, res) { } } +/** + * downloads and parses an sheet + * @returns parsed result + */ +export async function previewSheet(req, res) { + if (!req.body.sheetid) { + res.status(400).send({ message: 'missing sheet id' }); + return; + } + try { + const options = JSON.parse(req.body.options); + const data = await Sheet.parse(req.body.sheetid, req.body.worksheet, options); + res.status(200).send(data); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +} + +/** + * uploads Client secrets file + * @returns parsed result + */ +export async function sheetClientFile(req, res) { + if (!req.file) { + res.status(400).send({ message: 'File not found' }); + return; + } + + try { + const clientSecret = JSON.parse(req.body.options); + await Sheet.saveClientSecrets(clientSecret); + res.status(200).send('OK'); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +} + +/** + * @returns sheet auth state + */ +export async function sheetAuthState(req, res) { + const send = (await Sheet.authorized()) ? 'true' : 'false'; + res.status(200).send(send); +} + +/** + * @returns link to sheet auth url + */ +export async function sheetAuthUrl(req, res) { + const successful = await Sheet.openAuthServer(); + if (successful === false) { + res.status(500).send('bad'); + } else { + res.status(200).send(successful); + } +} + /** * Meant to create a new project file, it will clear only fields which are specific to a project * @param req diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index 67f4fb033..99d1c787f 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -19,6 +19,10 @@ import { postUserFields, postViewSettings, previewExcel, + sheetAuthUrl, + sheetAuthState, + sheetClientFile, + previewSheet, } from '../controllers/ontimeController.js'; import { @@ -49,6 +53,18 @@ router.patch('/db', validatePatchProjectFile, patchPartialProjectFile); // create route between controller and '/ontime/preview-spreadsheet' endpoint router.post('/preview-spreadsheet', uploadFile, previewExcel); +// create route between controller and '/ontime/preview-sheet' endpoint +router.post('/preview-sheet', uploadFile, previewSheet); + +// create route between controller and '/ontime/sheets-auth-client' endpoint +router.get('/sheets-auth-client', sheetClientFile); + +// create route between controller and '/ontime/sheets-auth-url' endpoint +router.get('/sheets-auth-url', sheetAuthUrl); + +// create route between controller and '/ontime/sheets-auth-state' endpoint +router.get('/sheets-auth-state', sheetAuthState); + // create route between controller and '/ontime/settings' endpoint router.get('/settings', getSettings); diff --git a/apps/server/src/utils/sheetsAuth.ts b/apps/server/src/utils/sheetsAuth.ts new file mode 100644 index 000000000..f9f36ff3f --- /dev/null +++ b/apps/server/src/utils/sheetsAuth.ts @@ -0,0 +1,250 @@ +import { OAuth2Client } from 'google-auth-library'; +import { readFile, writeFile } from 'fs/promises'; +import { google } from 'googleapis'; +import http from 'http'; +import { URL } from 'url'; +import { logger } from '../classes/Logger.js'; +import { getAppDataPath } from '../setup.js'; +import { DatabaseModel, LogOrigin } from 'ontime-types'; +import { ExcelImportOptions, isExcelImportMap } from 'ontime-utils'; +import { parseExcel } from './parser.js'; +import { parseProject, parseRundown, parseUserFields } from './parserFunctions.js'; + +type ResponseOK = { + data: Partial; +}; + +class sheet { + private static client: null | OAuth2Client = null; + private readonly scope = 'https://www.googleapis.com/auth/spreadsheets'; + private readonly client_secret = getAppDataPath() + '/client_secret.json'; + private readonly token = getAppDataPath() + '/token.json'; + private static authUrl: null | string = null; + + public async authorized(): Promise { + if (await this.loadToken()) { + if (await this.refreshToken()) { + return true; + } + } else { + return false; + } + } + + public async parse(sheetId: string, worksheet: string, options: ExcelImportOptions) { + if (!sheet.client) { + if (!(await this.authorized())) { + throw new Error(`Sheet not authorized`); + } + } + console.log(sheetId, worksheet, options); + const res: Partial = {}; + + if (!isExcelImportMap(options)) { + throw new Error('Got incorrect options to excel import', JSON.parse(options)); + } + + const rq = await google.sheets({ version: 'v4', auth: sheet.client }).spreadsheets.values.get({ + spreadsheetId: sheetId, + valueRenderOption: 'FORMATTED_VALUE', + majorDimension: 'ROWS', + range: worksheet + '!A:Z', //FIXME: this is an abitrary range + }); + if (rq.status === 200) { + res.data = {}; + const dataFromSheet = parseExcel(rq.data.values, options); + res.data.rundown = parseRundown(dataFromSheet); + if (res.data.rundown.length < 1) { + throw new Error(`Could not find data to import in the worksheet ${options.worksheet}`); + } + res.data.project = parseProject(dataFromSheet); + res.data.userFields = parseUserFields(dataFromSheet); + return res; + } else { + throw new Error(`Sheet read faild: ${rq.statusText}`); + } + } + + public async saveClientSecrets(secrets: Object) { + //TODO: test that this is actualy a client file? + //invalidate previus auths + sheet.client = null; + sheet.authUrl = null; + await writeFile(getAppDataPath() + '/credentials.json', JSON.stringify(secrets), 'utf-8'); + } + + private async saveToken() { + const payload = JSON.stringify({ + type: 'authorized_user', + client_id: sheet.client._clientId, + client_secret: sheet.client._clientSecret, + refresh_token: sheet.client.credentials.refresh_token, + }); + await writeFile(getAppDataPath() + '/token.json', payload, 'utf-8'); + } + + private async loadToken(): Promise { + try { + const token = JSON.parse(await readFile(getAppDataPath() + '/token.json', 'utf-8')); + sheet.client = new OAuth2Client({ clientId: token.client_id, clientSecret: token.client_secret }); + sheet.client.credentials.refresh_token = token.refresh_token; + return true; + } catch (err) { + // logger.error(LogOrigin.Server, `Sheets: ${err}`); + return false; + } + } + + async refreshToken(): Promise { + if (!sheet.client?.credentials?.refresh_token) return false; + try { + const response = await sheet.client.refreshAccessToken(); + if (response?.credentials) { + return true; + } + } catch (_) {} + return false; + } + + public async openAuthServer(): Promise { + if (sheet.authUrl) { + return sheet.authUrl; + } + const creadFile = await readFile(getAppDataPath() + '/credentials.json', 'utf-8').catch((err) => + logger.error(LogOrigin.Server, `${err}`), + ); + if (!creadFile) { + return false; + } + const keyFile = JSON.parse(creadFile); + const keys = keyFile.installed || keyFile.web; + if (!keys.redirect_uris || keys.redirect_uris.length === 0) { + logger.error(LogOrigin.Server, `${invalidRedirectUri}`); + return false; + } + + // create an oAuth client to authorize the API call + const redirectUri = new URL(keys.redirect_uris[0]); + if (redirectUri.hostname !== 'localhost') { + throw new Error(invalidRedirectUri); + } + + // create an oAuth client to authorize the API call + const client = new OAuth2Client({ + clientId: keys.client_id, + clientSecret: keys.client_secret, + }); + + const server = http.createServer(async (req, res) => { + try { + const serverUrl = new URL(req.url, 'http://localhost:3000'); + if (serverUrl.pathname !== redirectUri.pathname) { + res.end('Invalid callback URL'); + return; + } + const searchParams = serverUrl.searchParams; + if (searchParams.has('error')) { + res.end('Authorization rejected.'); + logger.info(LogOrigin.Server, `Sheet: ${searchParams.get('error')}`); + return; + } + if (!searchParams.has('code')) { + res.end('No authentication code provided.'); + logger.info(LogOrigin.Server, `Sheet: Cannot read authentication code`); + return; + } + const code = searchParams.get('code'); + const { tokens } = await client.getToken({ + code: code, + redirect_uri: redirectUri.toString(), + }); + client.credentials = tokens; + sheet.client = client; + this.saveToken(); + res.end('Authentication successful! Please close this tab and return to OnTime.'); + logger.info(LogOrigin.Server, `Sheet: Authentication successful`); + } catch (e) { + logger.error(LogOrigin.Server, `Sheet: ${e}`); + } finally { + server.close(); + } + }); + let listenPort = 3000; + if (keyFile.installed) { + // Use emphemeral port if not a web client + listenPort = 0; + } else if (redirectUri.port !== '') { + listenPort = Number(redirectUri.port); + } + server.listen(listenPort, () => { + const address = server.address(); + if (typeof address !== 'string') { + redirectUri.port = String(address.port); + } + // open the browser to the authorize url to start the workflow + const authorizeUrl = client.generateAuthUrl({ + redirect_uri: redirectUri.toString(), + access_type: 'offline', + scope: this.scope, + }); + sheet.authUrl = authorizeUrl; + return authorizeUrl; + }); + setTimeout(() => { + sheet.authUrl = null; + server.unref; + }, 2 * 60 * 1000); + } +} + +// Copyright 2020 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//TODO: add modification notifications as requrirde by the license + +const invalidRedirectUri = `The provided keyfile does not define a valid +redirect URI. There must be at least one redirect URI defined, and this sample +assumes it redirects to 'http://localhost:3000/oauth2callback'. Please edit +your keyfile, and add a 'redirect_uris' section. For example: + +"redirect_uris": [ + "http://localhost:3000/oauth2callback" +] +`; + +function hexToRgb(hex: string) { + if (hex === '' || hex[0] !== '#') { + return { red: 1, green: 1, blue: 1 }; + } + const bigint = parseInt(hex.slice(1), 16); + const r = ((bigint >> 16) & 255) / 255; + const g = ((bigint >> 8) & 255) / 255; + const b = (bigint & 255) / 255; + return { red: r, green: g, blue: b }; +} + +type sheetPos = { + row: number; + col: number; +}; + +function isEqualPartial(a: T, b: Partial) { + for (const [key, value] of Object.entries(a)) { + if (b[key] !== undefined && value !== b[key]) { + return false; + } + } + return true; +} + +export const Sheet = new sheet();