UNPKG

@accounter/server

Version:
179 lines (154 loc) 6.07 kB
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'; @Injectable({ scope: Scope.Singleton, global: true, }) export class GoogleDriveProvider { private apiKey: string; constructor(@Inject(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 }); } } }