mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 13:23:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54534012bd |
@@ -0,0 +1,179 @@
|
|||||||
|
import { Credentials, OAuth2Client } from 'google-auth-library';
|
||||||
|
import { logger } from '../../classes/Logger.js';
|
||||||
|
import { LogOrigin, MaybeString, AuthenticationStatus } from 'ontime-types';
|
||||||
|
import { consoleSubdued } from '../../utils/console.js';
|
||||||
|
import { type ClientSecret } from './sheets.utils.js';
|
||||||
|
|
||||||
|
const codesUrl = 'https://oauth2.googleapis.com/device/code';
|
||||||
|
const tokenUrl = 'https://oauth2.googleapis.com/token';
|
||||||
|
const grantType = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';
|
||||||
|
|
||||||
|
type CodesResponse = {
|
||||||
|
device_code: string;
|
||||||
|
expires_in: number;
|
||||||
|
interval: number;
|
||||||
|
user_code: string;
|
||||||
|
verification_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DeviceAuthProvider {
|
||||||
|
private currentAuthClient: OAuth2Client | null = null;
|
||||||
|
private currentAuthUrl: MaybeString = null;
|
||||||
|
private currentAuthCode: MaybeString = null;
|
||||||
|
private pollInterval: NodeJS.Timeout | null = null;
|
||||||
|
private cleanupTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
getStatus(): AuthenticationStatus {
|
||||||
|
if (this.cleanupTimeout) {
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
return this.currentAuthClient ? 'authenticated' : 'not_authenticated';
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthClient(): OAuth2Client | null {
|
||||||
|
return this.currentAuthClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingData() {
|
||||||
|
return {
|
||||||
|
verification_url: this.currentAuthUrl,
|
||||||
|
user_code: this.currentAuthCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke() {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.currentAuthClient = null;
|
||||||
|
this.currentAuthUrl = null;
|
||||||
|
this.currentAuthCode = null;
|
||||||
|
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
|
}
|
||||||
|
if (this.cleanupTimeout) {
|
||||||
|
clearTimeout(this.cleanupTimeout);
|
||||||
|
this.cleanupTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(clientSecret: ClientSecret, onAuthenticated: (client: OAuth2Client) => Promise<void>) {
|
||||||
|
const { device_code, expires_in, interval, user_code, verification_url } = await this.getDeviceCodes(clientSecret);
|
||||||
|
this.currentAuthUrl = verification_url;
|
||||||
|
this.currentAuthCode = user_code;
|
||||||
|
|
||||||
|
this.verifyConnection(clientSecret, device_code, interval, expires_in, onAuthenticated);
|
||||||
|
|
||||||
|
return { verification_url, user_code };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse> {
|
||||||
|
const response = await fetch(codesUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientSecret.installed.client_id,
|
||||||
|
scope: sheetScope,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceCodes: CodesResponse = await response.json();
|
||||||
|
return deviceCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyConnection(
|
||||||
|
clientSecret: ClientSecret,
|
||||||
|
device_code: string,
|
||||||
|
interval: number,
|
||||||
|
expires_in: number,
|
||||||
|
onAuthenticated: (client: OAuth2Client) => Promise<void>,
|
||||||
|
) {
|
||||||
|
logger.info(LogOrigin.Server, 'Start polling for auth...');
|
||||||
|
|
||||||
|
this.pollInterval = setInterval(() => this.pollForAuth(clientSecret, device_code, onAuthenticated), interval * 1000);
|
||||||
|
|
||||||
|
if (this.cleanupTimeout) {
|
||||||
|
clearTimeout(this.cleanupTimeout);
|
||||||
|
}
|
||||||
|
this.cleanupTimeout = setTimeout(() => {
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
|
}
|
||||||
|
this.cleanupTimeout = null;
|
||||||
|
}, expires_in * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollForAuth(
|
||||||
|
clientSecret: ClientSecret,
|
||||||
|
device_code: string,
|
||||||
|
onAuthenticated: (client: OAuth2Client) => Promise<void>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientSecret.installed.client_id,
|
||||||
|
client_secret: clientSecret.installed.client_secret,
|
||||||
|
device_code,
|
||||||
|
grant_type: grantType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 428) {
|
||||||
|
consoleSubdued('User not auth yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error(LogOrigin.Server, `Authentication poll failed with code: ${response.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth: Credentials = await response.json();
|
||||||
|
|
||||||
|
logger.info(LogOrigin.Server, 'Successfully Authenticated');
|
||||||
|
const client = new OAuth2Client({
|
||||||
|
clientId: clientSecret.installed.client_id,
|
||||||
|
clientSecret: clientSecret.installed.client_secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.setCredentials({
|
||||||
|
refresh_token: auth.refresh_token,
|
||||||
|
access_token: auth.access_token,
|
||||||
|
scope: auth.scope,
|
||||||
|
token_type: auth.token_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentAuthClient = client;
|
||||||
|
|
||||||
|
if (this.cleanupTimeout) {
|
||||||
|
clearTimeout(this.cleanupTimeout);
|
||||||
|
this.cleanupTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onAuthenticated(client);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(LogOrigin.Server, `Authentication poll error: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { sheets, type sheets_v4 } from '@googleapis/sheets';
|
||||||
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
|
|
||||||
|
export class GoogleSheetsClient {
|
||||||
|
private sheets: sheets_v4.Sheets;
|
||||||
|
|
||||||
|
constructor(auth: OAuth2Client) {
|
||||||
|
this.sheets = sheets({ version: 'v4', auth });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSpreadsheet(spreadsheetId: string) {
|
||||||
|
return this.sheets.spreadsheets.get({
|
||||||
|
spreadsheetId,
|
||||||
|
includeGridData: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getValues(spreadsheetId: string, range: string) {
|
||||||
|
return this.sheets.spreadsheets.values.get({
|
||||||
|
spreadsheetId,
|
||||||
|
valueRenderOption: 'FORMATTED_VALUE',
|
||||||
|
majorDimension: 'ROWS',
|
||||||
|
range,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchUpdate(
|
||||||
|
spreadsheetId: string,
|
||||||
|
requests: sheets_v4.Schema$Request[],
|
||||||
|
responseRanges?: string[],
|
||||||
|
) {
|
||||||
|
return this.sheets.spreadsheets.batchUpdate({
|
||||||
|
spreadsheetId,
|
||||||
|
requestBody: {
|
||||||
|
includeSpreadsheetInResponse: false,
|
||||||
|
responseRanges,
|
||||||
|
requests,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { DeviceAuthProvider } from '../DeviceAuthProvider.js';
|
||||||
|
|
||||||
|
describe('DeviceAuthProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start with not_authenticated status', () => {
|
||||||
|
const provider = new DeviceAuthProvider();
|
||||||
|
expect(provider.getStatus()).toBe('not_authenticated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initiate authentication and poll', async () => {
|
||||||
|
const provider = new DeviceAuthProvider();
|
||||||
|
const mockClientSecret = {
|
||||||
|
installed: {
|
||||||
|
client_id: 'id',
|
||||||
|
client_secret: 'secret',
|
||||||
|
auth_uri: '',
|
||||||
|
token_uri: '',
|
||||||
|
auth_provider_x509_cert_url: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
device_code: 'dc',
|
||||||
|
expires_in: 60,
|
||||||
|
interval: 5,
|
||||||
|
user_code: 'uc',
|
||||||
|
verification_url: 'vurl',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAuth = vi.fn();
|
||||||
|
const result = await provider.authenticate(mockClientSecret, onAuth);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
verification_url: 'vurl',
|
||||||
|
user_code: 'uc',
|
||||||
|
});
|
||||||
|
expect(provider.getStatus()).toBe('pending');
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Poll 1: not yet authorized
|
||||||
|
(fetch as any).mockResolvedValueOnce({
|
||||||
|
status: 428,
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
// Poll happens in background
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(provider.getStatus()).toBe('pending');
|
||||||
|
|
||||||
|
// Poll 2: success
|
||||||
|
(fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'at',
|
||||||
|
refresh_token: 'rt',
|
||||||
|
scope: 'scope',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
await vi.waitFor(() => expect(onAuth).toHaveBeenCalled());
|
||||||
|
expect(provider.getStatus()).toBe('authenticated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop polling after expiration', async () => {
|
||||||
|
const provider = new DeviceAuthProvider();
|
||||||
|
const mockClientSecret = {
|
||||||
|
installed: {
|
||||||
|
client_id: 'id',
|
||||||
|
client_secret: 'secret',
|
||||||
|
auth_uri: '',
|
||||||
|
token_uri: '',
|
||||||
|
auth_provider_x509_cert_url: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
device_code: 'dc',
|
||||||
|
expires_in: 10,
|
||||||
|
interval: 5,
|
||||||
|
user_code: 'uc',
|
||||||
|
verification_url: 'vurl',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.authenticate(mockClientSecret, vi.fn());
|
||||||
|
expect(provider.getStatus()).toBe('pending');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(11000);
|
||||||
|
expect(provider.getStatus()).toBe('not_authenticated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset on revoke', async () => {
|
||||||
|
const provider = new DeviceAuthProvider();
|
||||||
|
const mockClientSecret = {
|
||||||
|
installed: {
|
||||||
|
client_id: 'id',
|
||||||
|
client_secret: 'secret',
|
||||||
|
auth_uri: '',
|
||||||
|
token_uri: '',
|
||||||
|
auth_provider_x509_cert_url: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
device_code: 'dc',
|
||||||
|
expires_in: 60,
|
||||||
|
interval: 5,
|
||||||
|
user_code: 'uc',
|
||||||
|
verification_url: 'vurl',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.authenticate(mockClientSecret, vi.fn());
|
||||||
|
provider.revoke();
|
||||||
|
expect(provider.getStatus()).toBe('not_authenticated');
|
||||||
|
expect(provider.getAuthClient()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { sheets } from '@googleapis/sheets';
|
||||||
|
import { GoogleSheetsClient } from '../GoogleSheetsClient.js';
|
||||||
|
|
||||||
|
vi.mock('@googleapis/sheets', () => ({
|
||||||
|
sheets: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GoogleSheetsClient', () => {
|
||||||
|
let mockSheets: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSheets = {
|
||||||
|
spreadsheets: {
|
||||||
|
get: vi.fn(),
|
||||||
|
values: {
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
batchUpdate: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(sheets as any).mockReturnValue(mockSheets);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call spreadsheets.get', async () => {
|
||||||
|
const client = new GoogleSheetsClient({} as any);
|
||||||
|
mockSheets.spreadsheets.get.mockResolvedValue({ data: {} });
|
||||||
|
await client.getSpreadsheet('id');
|
||||||
|
expect(mockSheets.spreadsheets.get).toHaveBeenCalledWith({
|
||||||
|
spreadsheetId: 'id',
|
||||||
|
includeGridData: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call spreadsheets.values.get', async () => {
|
||||||
|
const client = new GoogleSheetsClient({} as any);
|
||||||
|
mockSheets.spreadsheets.values.get.mockResolvedValue({ data: {} });
|
||||||
|
await client.getValues('id', 'range');
|
||||||
|
expect(mockSheets.spreadsheets.values.get).toHaveBeenCalledWith({
|
||||||
|
spreadsheetId: 'id',
|
||||||
|
valueRenderOption: 'FORMATTED_VALUE',
|
||||||
|
majorDimension: 'ROWS',
|
||||||
|
range: 'range',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call spreadsheets.batchUpdate', async () => {
|
||||||
|
const client = new GoogleSheetsClient({} as any);
|
||||||
|
mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ data: {} });
|
||||||
|
await client.batchUpdate('id', [{ updateCells: {} }], ['range']);
|
||||||
|
expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith({
|
||||||
|
spreadsheetId: 'id',
|
||||||
|
requestBody: {
|
||||||
|
includeSpreadsheetInResponse: false,
|
||||||
|
responseRanges: ['range'],
|
||||||
|
requests: [{ updateCells: {} }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,8 +20,8 @@ import {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { ImportMap, getErrorMessage } from 'ontime-utils';
|
import { ImportMap, getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import { sheets, type sheets_v4 } from '@googleapis/sheets';
|
import { type sheets_v4 } from '@googleapis/sheets';
|
||||||
import { Credentials, OAuth2Client } from 'google-auth-library';
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
|
|
||||||
import { logger } from '../../classes/Logger.js';
|
import { logger } from '../../classes/Logger.js';
|
||||||
import { parseRundowns } from '../rundown/rundown.parser.js';
|
import { parseRundowns } from '../rundown/rundown.parser.js';
|
||||||
@@ -29,42 +29,18 @@ import { parseRundowns } from '../rundown/rundown.parser.js';
|
|||||||
import { getCurrentRundown, getProjectCustomFields, processRundown } from '../rundown/rundown.dao.js';
|
import { getCurrentRundown, getProjectCustomFields, processRundown } from '../rundown/rundown.dao.js';
|
||||||
import { parseExcel } from '../excel/excel.parser.js';
|
import { parseExcel } from '../excel/excel.parser.js';
|
||||||
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||||
import { consoleSubdued } from '../../utils/console.js';
|
|
||||||
|
|
||||||
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheets.utils.js';
|
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheets.utils.js';
|
||||||
import { catchCommonImportXlsxError } from './googleApi.utils.js';
|
import { catchCommonImportXlsxError } from './googleApi.utils.js';
|
||||||
|
import { DeviceAuthProvider } from './DeviceAuthProvider.js';
|
||||||
|
import { GoogleSheetsClient } from './GoogleSheetsClient.js';
|
||||||
|
|
||||||
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';
|
const authProvider = new DeviceAuthProvider();
|
||||||
const codesUrl = 'https://oauth2.googleapis.com/device/code';
|
|
||||||
const tokenUrl = 'https://oauth2.googleapis.com/token';
|
|
||||||
const grantType = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
||||||
|
|
||||||
let currentAuthClient: OAuth2Client | null = null;
|
|
||||||
let currentClientSecret: ClientSecret | null = null;
|
|
||||||
let currentAuthUrl: MaybeString = null;
|
|
||||||
let currentAuthCode: MaybeString = null;
|
|
||||||
|
|
||||||
let currentSheetId: MaybeString = null;
|
let currentSheetId: MaybeString = null;
|
||||||
|
|
||||||
let pollInterval: NodeJS.Timeout | null = null;
|
|
||||||
let cleanupTimeout: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
currentAuthClient = null;
|
authProvider.revoke();
|
||||||
currentClientSecret = null;
|
|
||||||
currentAuthUrl = null;
|
|
||||||
currentAuthCode = null;
|
|
||||||
|
|
||||||
currentSheetId = null;
|
currentSheetId = null;
|
||||||
|
|
||||||
if (pollInterval) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
pollInterval = null;
|
|
||||||
}
|
|
||||||
if (cleanupTimeout) {
|
|
||||||
clearTimeout(cleanupTimeout);
|
|
||||||
cleanupTimeout = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,148 +71,20 @@ export function handleClientSecret(clientSecret: string): ClientSecret {
|
|||||||
return clientSecretObject;
|
return clientSecretObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://developers.google.com/identity/protocols/oauth2/limited-input-device#success-response
|
|
||||||
type CodesResponse = {
|
|
||||||
device_code: string;
|
|
||||||
expires_in: number;
|
|
||||||
interval: number;
|
|
||||||
user_code: string;
|
|
||||||
verification_url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Establishes connection with Google Auth server and retrieves device codes
|
|
||||||
*/
|
|
||||||
async function getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse> {
|
|
||||||
const response = await fetch(codesUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
client_id: clientSecret.installed.client_id,
|
|
||||||
scope: sheetScope,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceCodes: CodesResponse = await response.json();
|
|
||||||
return deviceCodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets credentials from Google Auth server
|
|
||||||
*/
|
|
||||||
function verifyConnection(
|
|
||||||
clientSecret: ClientSecret,
|
|
||||||
device_code: string,
|
|
||||||
interval: number,
|
|
||||||
expires_in: number,
|
|
||||||
postAction: () => Promise<any>,
|
|
||||||
) {
|
|
||||||
logger.info(LogOrigin.Server, 'Start polling for auth...');
|
|
||||||
|
|
||||||
// create poller to check for auth
|
|
||||||
pollInterval = setInterval(pollForAuth, interval * 1000);
|
|
||||||
|
|
||||||
// schedule to clear the poller when we know the token is no longer valid
|
|
||||||
if (cleanupTimeout) {
|
|
||||||
clearTimeout(cleanupTimeout);
|
|
||||||
cleanupTimeout = null;
|
|
||||||
}
|
|
||||||
cleanupTimeout = setTimeout(() => {
|
|
||||||
if (pollInterval) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
pollInterval = null;
|
|
||||||
}
|
|
||||||
}, expires_in * 1000);
|
|
||||||
|
|
||||||
async function pollForAuth() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(tokenUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
client_id: clientSecret.installed.client_id,
|
|
||||||
client_secret: clientSecret.installed.client_secret,
|
|
||||||
device_code,
|
|
||||||
grant_type: grantType,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// server returns 428 if user hasnt yet completed the auth process
|
|
||||||
if (response.status === 428) {
|
|
||||||
consoleSubdued('User not auth yet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
logger.error(LogOrigin.Server, `Authentication poll failed with code: ${response.status}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth: Credentials = await response.json();
|
|
||||||
|
|
||||||
logger.info(LogOrigin.Server, 'Successfully Authenticated');
|
|
||||||
const client = new OAuth2Client({
|
|
||||||
clientId: clientSecret.installed.client_id,
|
|
||||||
clientSecret: clientSecret.installed.client_secret,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.setCredentials({
|
|
||||||
refresh_token: auth.refresh_token,
|
|
||||||
access_token: auth.access_token,
|
|
||||||
scope: auth.scope,
|
|
||||||
token_type: auth.token_type,
|
|
||||||
});
|
|
||||||
|
|
||||||
// save client and cancel tasks
|
|
||||||
currentAuthClient = client;
|
|
||||||
|
|
||||||
if (cleanupTimeout) {
|
|
||||||
clearTimeout(cleanupTimeout);
|
|
||||||
cleanupTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pollInterval) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
pollInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await postAction();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(LogOrigin.Server, `Authentication poll error: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } {
|
export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } {
|
||||||
if (!currentSheetId) {
|
if (!currentSheetId) {
|
||||||
throw new Error('No sheet ID');
|
throw new Error('No sheet ID');
|
||||||
}
|
}
|
||||||
if (cleanupTimeout) {
|
return { authenticated: authProvider.getStatus(), sheetId: currentSheetId };
|
||||||
return { authenticated: 'pending', sheetId: currentSheetId };
|
|
||||||
}
|
|
||||||
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated', sheetId: currentSheetId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifySheet(
|
async function verifySheet(
|
||||||
sheetId = currentSheetId,
|
sheetId: string,
|
||||||
authClient = currentAuthClient,
|
authClient: OAuth2Client,
|
||||||
): Promise<{ worksheetOptions: string[] }> {
|
): Promise<{ worksheetOptions: string[] }> {
|
||||||
if (!sheetId || !authClient) {
|
|
||||||
throw new Error('Missing sheet ID or authentication');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({
|
const client = new GoogleSheetsClient(authClient);
|
||||||
spreadsheetId: sheetId,
|
const spreadsheets = await client.getSpreadsheet(sheetId);
|
||||||
includeGridData: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const worksheets: string[] = [];
|
const worksheets: string[] = [];
|
||||||
spreadsheets.data.sheets?.forEach((sheet) => {
|
spreadsheets.data.sheets?.forEach((sheet) => {
|
||||||
@@ -261,26 +109,17 @@ export async function handleInitialConnection(
|
|||||||
clientSecret: ClientSecret,
|
clientSecret: ClientSecret,
|
||||||
sheetId: string,
|
sheetId: string,
|
||||||
): Promise<{ verification_url: string; user_code: string }> {
|
): Promise<{ verification_url: string; user_code: string }> {
|
||||||
currentClientSecret = clientSecret;
|
|
||||||
|
|
||||||
// we know there is an ongoing process if there is a timeout for cleanup
|
|
||||||
// if there is an ongoing process, we return its data
|
|
||||||
if (cleanupTimeout) {
|
|
||||||
if (!currentAuthUrl || !currentAuthCode) {
|
|
||||||
throw new Error('No ongoing connection');
|
|
||||||
}
|
|
||||||
return { verification_url: currentAuthUrl, user_code: currentAuthCode };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { device_code, expires_in, interval, user_code, verification_url } = await getDeviceCodes(currentClientSecret);
|
|
||||||
currentAuthUrl = verification_url;
|
|
||||||
currentAuthCode = user_code;
|
|
||||||
currentSheetId = sheetId;
|
currentSheetId = sheetId;
|
||||||
|
|
||||||
// schedule verifying token and the existence of the sheetID
|
if (authProvider.getStatus() === 'pending') {
|
||||||
verifyConnection(currentClientSecret, device_code, interval, expires_in, verifySheet);
|
const pendingData = authProvider.getPendingData();
|
||||||
|
if (!pendingData.verification_url || !pendingData.user_code) {
|
||||||
|
throw new Error('No ongoing connection');
|
||||||
|
}
|
||||||
|
return pendingData as { verification_url: string; user_code: string };
|
||||||
|
}
|
||||||
|
|
||||||
return { verification_url, user_code };
|
return authProvider.authenticate(clientSecret, (client) => verifySheet(sheetId, client).then(() => {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -288,22 +127,21 @@ export async function handleInitialConnection(
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function getWorksheetOptions(sheetId: string): ReturnType<typeof verifySheet> {
|
export async function getWorksheetOptions(sheetId: string): ReturnType<typeof verifySheet> {
|
||||||
if (!currentAuthClient) {
|
const authClient = authProvider.getAuthClient();
|
||||||
|
if (!authClient) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
currentSheetId = sheetId;
|
currentSheetId = sheetId;
|
||||||
|
|
||||||
return verifySheet(sheetId);
|
return verifySheet(sheetId, authClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> {
|
async function verifyWorksheet(
|
||||||
if (!currentAuthClient) {
|
client: GoogleSheetsClient,
|
||||||
throw new Error('Not authenticated');
|
sheetId: string,
|
||||||
}
|
worksheet: string,
|
||||||
|
): Promise<{ worksheetId: number; range: string }> {
|
||||||
const spreadsheets = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.get({
|
const spreadsheets = await client.getSpreadsheet(sheetId);
|
||||||
spreadsheetId: sheetId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (spreadsheets.status !== 200) {
|
if (spreadsheets.status !== 200) {
|
||||||
throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`);
|
throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`);
|
||||||
@@ -320,13 +158,7 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo
|
|||||||
if (!selectedWorksheet) {
|
if (!selectedWorksheet) {
|
||||||
throw new Error('Could not find worksheet');
|
throw new Error('Could not find worksheet');
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
The first spreadsheet provided by google sheet has an id = 0,
|
|
||||||
so !0 returns true, the only other number that returns true in this setup is NaN,
|
|
||||||
so if x !== 0 && x !== NaN, then !x returns false, we indeed want !NaN to return true,
|
|
||||||
but we would like !0 to return false, reason why is also checked that the id is not 0,
|
|
||||||
because if it is 0, then I should not enter the condition.
|
|
||||||
*/
|
|
||||||
if (
|
if (
|
||||||
!selectedWorksheet.properties ||
|
!selectedWorksheet.properties ||
|
||||||
(!selectedWorksheet.properties.sheetId && selectedWorksheet.properties.sheetId !== 0)
|
(!selectedWorksheet.properties.sheetId && selectedWorksheet.properties.sheetId !== 0)
|
||||||
@@ -342,18 +174,15 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function upload(sheetId: string, options: ImportMap) {
|
export async function upload(sheetId: string, options: ImportMap) {
|
||||||
if (!currentAuthClient) {
|
const authClient = authProvider.getAuthClient();
|
||||||
|
if (!authClient) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
|
const client = new GoogleSheetsClient(authClient);
|
||||||
|
|
||||||
const { worksheetId, range } = await verifyWorksheet(sheetId, options.worksheet);
|
const { worksheetId, range } = await verifyWorksheet(client, sheetId, options.worksheet);
|
||||||
|
|
||||||
const readResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
|
const readResponse = await client.getValues(sheetId, range);
|
||||||
spreadsheetId: sheetId,
|
|
||||||
valueRenderOption: 'FORMATTED_VALUE',
|
|
||||||
majorDimension: 'ROWS',
|
|
||||||
range,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (readResponse.status !== 200 || !readResponse.data.values) {
|
if (readResponse.status !== 200 || !readResponse.data.values) {
|
||||||
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
|
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
|
||||||
@@ -429,14 +258,7 @@ export async function upload(sheetId: string, options: ImportMap) {
|
|||||||
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`);
|
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
|
const writeResponse = await client.batchUpdate(sheetId, updateRundown, [range]);
|
||||||
spreadsheetId: sheetId,
|
|
||||||
requestBody: {
|
|
||||||
includeSpreadsheetInResponse: false,
|
|
||||||
responseRanges: [range],
|
|
||||||
requests: updateRundown,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (writeResponse.status === 200) {
|
if (writeResponse.status === 200) {
|
||||||
logger.info(LogOrigin.Server, `Sheet write ${writeResponse.statusText}`);
|
logger.info(LogOrigin.Server, `Sheet write ${writeResponse.statusText}`);
|
||||||
@@ -459,18 +281,15 @@ export async function download(
|
|||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
summary: RundownSummary;
|
summary: RundownSummary;
|
||||||
}> {
|
}> {
|
||||||
if (!currentAuthClient) {
|
const authClient = authProvider.getAuthClient();
|
||||||
|
if (!authClient) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
|
const client = new GoogleSheetsClient(authClient);
|
||||||
|
|
||||||
const { range } = await verifyWorksheet(sheetId, options.worksheet);
|
const { range } = await verifyWorksheet(client, sheetId, options.worksheet);
|
||||||
|
|
||||||
const googleResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
|
const googleResponse = await client.getValues(sheetId, range);
|
||||||
spreadsheetId: sheetId,
|
|
||||||
valueRenderOption: 'FORMATTED_VALUE',
|
|
||||||
majorDimension: 'ROWS',
|
|
||||||
range,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (googleResponse.status !== 200) {
|
if (googleResponse.status !== 200) {
|
||||||
throw new Error(`Sheet read failed: ${googleResponse.statusText}`);
|
throw new Error(`Sheet read failed: ${googleResponse.statusText}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user