@accounter/server
Version:
Accounter GraphQL server
179 lines (154 loc) • 6.07 kB
text/typescript
import { Inject, Injectable, Scope } from 'graphql-modules';
import { ENVIRONMENT } from '../../../shared/tokens.js';
import type { Environment } from '../../../shared/types/index.js';
import {
fileSchema,
folderContentSchema,
type DriveFileContent,
type DriveFolderContent,
} from './types/folder-content.js';
({
scope: Scope.Singleton,
global: true,
})
export class GoogleDriveProvider {
private apiKey: string;
constructor((ENVIRONMENT) private env: Environment) {
this.apiKey = this.env.googleDrive?.driveApiKey ?? '';
}
private async fetchFolderContent(folderUrl: string): Promise<DriveFolderContent> {
const mainUrl = folderUrl.split('?')[0];
const folderId = mainUrl.split('folders/')[1];
if (!folderId) {
throw new Error('Invalid Google Drive URL');
}
const url = new URL(
`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents&key=${this.apiKey}`,
);
const res = await fetch(url).catch(err => {
const message = `Failed fetching data from Google Drive for URL="${folderUrl}"`;
console.error(`${message}: ${err}`);
throw new Error(message);
});
const jsonResponse = await res.json().catch(err => {
const message = `Failed parsing response from Google Drive`;
console.error(`${message}: ${err}`);
throw new Error(message);
});
const parsedResponse = folderContentSchema.safeParse(jsonResponse);
if (!parsedResponse.success) {
const message = `Failed to parse response from Google Drive`;
console.error(message, parsedResponse.error);
throw new Error(message);
}
return parsedResponse.data;
}
private async fetchFile(fileInfo: DriveFileContent): Promise<File> {
const fileId = fileInfo.id;
const url = new URL(`https://drive.google.com/uc?export=download&id=${fileId}`);
// fetch file
const response = await fetch(url).catch(err => {
const message = `Failed fetching file from Google Drive for file="${fileInfo.name}"`;
console.error(`${message}: ${err}`);
throw new Error(message);
});
const buffer = await response.arrayBuffer().catch(err => {
const message = `Failed parsing file from Google Drive for file="${fileInfo.name}"`;
console.error(`${message}: ${err}`);
throw new Error(message);
});
const file = new File([buffer], fileInfo.name, { type: fileInfo.mimeType });
return file;
}
private isRelevantFileType(originalMimeType: string): boolean {
const mimeType = originalMimeType.toLowerCase();
return mimeType === 'application/pdf' || mimeType.startsWith('image/');
}
/**
* Whether a URL points at a single Google Drive *file* (as opposed to a
* folder, which {@link fetchFilesFromSharedFolder} handles).
*/
public static isFileUrl(rawUrl: string): boolean {
return GoogleDriveProvider.extractFileId(rawUrl) !== null;
}
/**
* Pull the file id out of the several share-link shapes Drive hands out:
* `/file/d/<id>/view`, `/open?id=<id>`, `/uc?id=<id>`.
*/
private static extractFileId(rawUrl: string): string | null {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return null;
}
const host = url.hostname.toLowerCase();
if (host !== 'drive.google.com' && host !== 'docs.google.com') {
return null;
}
const pathMatch = /\/(?:file|document|spreadsheets|presentation)\/d\/([^/?#]+)/.exec(
url.pathname,
);
if (pathMatch) {
return pathMatch[1];
}
const idParam = url.searchParams.get('id');
return idParam && idParam.length > 0 ? idParam : null;
}
private async fetchFileMetadata(fileId: string): Promise<DriveFileContent> {
const url = new URL(
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=id,name,mimeType,kind&key=${this.apiKey}`,
);
const res = await fetch(url).catch(err => {
const message = `Failed fetching file metadata from Google Drive for id="${fileId}"`;
console.error(`${message}: ${err}`);
throw new Error(message);
});
if (!res.ok) {
// Almost always "not shared with the API key" rather than "missing".
throw new Error(
`Google Drive returned HTTP ${res.status} for file id="${fileId}". Make sure the file is shared with anyone holding the link.`,
);
}
const parsed = fileSchema.safeParse(await res.json().catch(() => null));
if (!parsed.success) {
throw new Error(`Failed to parse Google Drive metadata for file id="${fileId}"`);
}
return parsed.data;
}
/**
* Fetch a single file from a Drive share link.
*
* A share link is not a download link — `/file/d/<id>/view` answers with an
* HTML page — so the id is resolved through the Drive API for its real name
* and MIME type before the bytes are pulled.
*/
public async fetchFileFromUrl(fileUrl: string): Promise<File> {
const fileId = GoogleDriveProvider.extractFileId(fileUrl);
if (!fileId) {
throw new Error(`Not a Google Drive file URL: "${fileUrl}"`);
}
const metadata = await this.fetchFileMetadata(fileId);
if (!this.isRelevantFileType(metadata.mimeType)) {
throw new Error(
`Unsupported Google Drive file type "${metadata.mimeType}" for file="${metadata.name}" — expected a PDF or an image`,
);
}
return this.fetchFile(metadata);
}
public async fetchFilesFromSharedFolder(folderUrl: string): Promise<File[]> {
try {
const folderData = await this.fetchFolderContent(folderUrl);
const relevantFiles = folderData.files.filter(file => this.isRelevantFileType(file.mimeType));
if (!relevantFiles.length) {
return [];
}
const files = await Promise.all(relevantFiles.map(fileInfo => this.fetchFile(fileInfo)));
return files;
} catch (e) {
const message = `Failed fetching files from Google Drive`;
console.error(`${message}: ${e}`);
throw new Error(message, { cause: e });
}
}
}