UNPKG

@uppy/provider-views

Version:

View library for Uppy remote provider plugins.

561 lines (501 loc) 16.9 kB
import type { MutableRef } from 'preact/hooks' // https://developers.google.com/photos/picker/reference/rest/v1/mediaItems // Note that the google api doc is not correct, hence some things are optional here but not in their docs export interface MediaItemBase { id: string createTime: string } interface MediaFileMetadataBase { width: number height: number } interface MediaFileBase { baseUrl: string mimeType: string filename: string } export interface VideoMediaItem extends MediaItemBase { type: 'VIDEO' mediaFile: MediaFileBase & { mediaFileMetadata: MediaFileMetadataBase & { videoMetadata: { cameraMake?: string cameraModel?: string fps?: number processingStatus: 'UNSPECIFIED' | 'PROCESSING' | 'READY' | 'FAILED' } } } } export interface PhotoMediaItem extends MediaItemBase { type: 'PHOTO' mediaFile: MediaFileBase & { mediaFileMetadata: MediaFileMetadataBase & { photoMetadata?: { cameraMake?: string cameraModel?: string focalLength?: number apertureFNumber?: number isoEquivalent?: number exposureTime?: string } } } } export interface UnspecifiedMediaItem extends MediaItemBase { type: 'TYPE_UNSPECIFIED' mediaFile: MediaFileBase & { mediaFileMetadata: MediaFileMetadataBase } } export type MediaItem = VideoMediaItem | PhotoMediaItem | UnspecifiedMediaItem export type MediaType = MediaItem['type'] // https://developers.google.com/photos/picker/reference/rest/v1/sessions export interface PickingSession { id: string pickerUri: string pollingConfig: { pollInterval: string timeoutIn: string } expireTime: string mediaItemsSet: boolean } export interface PickedItemBase { id: string mimeType: string name: string } export interface PickedDriveItem extends PickedItemBase { platform: 'drive' } export interface PickedPhotosItem extends PickedItemBase { platform: 'photos' url: string metadata?: Record<string, string | number> // I think string and number is OK in Companion } export type PickedItem = PickedPhotosItem | PickedDriveItem type PickerType = 'drive' | 'photos' const getAuthHeader = (token: string) => ({ authorization: `Bearer ${token}`, }) const injectedScripts = new Set<string>() let driveApiLoaded = false // https://stackoverflow.com/a/39008859/6519037 async function injectScript(src: string) { if (injectedScripts.has(src)) return await new Promise<void>((resolve, reject) => { const script = document.createElement('script') script.src = src script.addEventListener('load', () => resolve()) script.addEventListener('error', (e) => reject(e.error)) document.head.appendChild(script) }) injectedScripts.add(src) } export async function ensureScriptsInjected( pickerType: PickerType, ): Promise<void> { await Promise.all([ injectScript('https://accounts.google.com/gsi/client'), // Google Identity Services (async () => { await injectScript('https://apis.google.com/js/api.js') if (pickerType === 'drive' && !driveApiLoaded) { await new Promise<void>((resolve) => gapi.load('client:picker', () => resolve()), ) await gapi.client.load( 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest', ) driveApiLoaded = true } })(), ]) } async function isTokenValid( accessToken: string, signal: AbortSignal | undefined, ) { const response = await fetch( `https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${encodeURIComponent(accessToken)}`, { signal }, ) if (response.ok) { return true } // console.warn('Token is invalid or expired:', response.status, await response.text()); // Token is invalid or expired return false } export async function authorize({ pickerType, clientId, accessToken, }: { pickerType: PickerType clientId: string accessToken?: string | null | undefined }): Promise<string> { const response = await new Promise<google.accounts.oauth2.TokenResponse>( (resolve, reject) => { const scopes = pickerType === 'drive' ? ['https://www.googleapis.com/auth/drive.file'] : ['https://www.googleapis.com/auth/photospicker.mediaitems.readonly'] const tokenClient = google.accounts.oauth2.initTokenClient({ client_id: clientId, // Authorization scopes required by the API; multiple scopes can be included, separated by spaces. scope: scopes.join(' '), callback: resolve, error_callback: reject, }) if (accessToken === null) { // Prompt the user to select a Google Account and ask for consent to share their data // when establishing a new session. tokenClient.requestAccessToken({ prompt: 'consent' }) } else { // Skip display of account chooser and consent dialog for an existing session. tokenClient.requestAccessToken({ prompt: '' }) } }, ) if (response.error) { throw new Error(`OAuth2 error: ${response.error}`) } return response.access_token } export async function logout(accessToken: string): Promise<void> { await new Promise<void>((resolve) => google.accounts.oauth2.revoke(accessToken, resolve), ) } export class InvalidTokenError extends Error { constructor() { super('Invalid or expired token') this.name = 'InvalidTokenError' } } export async function showDrivePicker({ token, apiKey, appId, onFilesPicked, signal, onLoadingChange, onError, }: { token: string apiKey: string appId: string onFilesPicked: (files: PickedItem[], accessToken: string) => void signal: AbortSignal | undefined onLoadingChange: (loading: boolean) => void onError: (err: unknown) => void }): Promise<void> { // google drive picker will crash hard if given an invalid token, so we need to check it first // https://github.com/transloadit/uppy/pull/5443#pullrequestreview-2452439265 if (!(await isTokenValid(token, signal))) { throw new InvalidTokenError() } async function handleDocObjectRecursively({ doc, token, signal, }: { doc: { id: string name: string mimeType: string shortcutDetails?: { targetMimeType: string } } token: string signal?: AbortSignal }): Promise<PickedDriveItem[]> { if (doc.mimeType === 'application/vnd.google-apps.shortcut') { if ( doc.shortcutDetails?.targetMimeType === 'application/vnd.google-apps.folder' ) { // If we were to recurse into shortcuts to folders, it could get a bit crazy. We could end up picking things outside of the user's intended scope as well as infinite loops // If we were to just pass it through as-is, Companion would not be able to download it, so we just ignore it entirely return [] } // for other shortcut types, we just treat them as normal files and pass them to Companion to resolve return [ { platform: 'drive', id: doc.id, name: doc.name, mimeType: doc.mimeType, }, ] } if (doc.mimeType !== 'application/vnd.google-apps.folder') { return [ { platform: 'drive', id: doc.id, name: doc.name, mimeType: doc.mimeType, }, ] } const headers = getAuthHeader(token) const items: PickedDriveItem[] = [] let pageToken: string | undefined do { const params = new URLSearchParams({ q: `'${doc.id.replace(/'/g, "\\'")}' in parents and trashed = false`, fields: 'nextPageToken, files(id, name, mimeType, shortcutDetails(targetMimeType))', includeItemsFromAllDrives: 'true', supportsAllDrives: 'true', pageSize: '1000', ...(pageToken && { pageToken }), }) const res = await fetch( `https://www.googleapis.com/drive/v3/files?${params.toString()}`, { headers, signal }, ) if (!res.ok) { throw new Error( `Failed to list folder contents for '${doc.name}' (${doc.id}): ${res.status} ${res.statusText}`, ) } const json: { nextPageToken?: string; files: PickedItemBase[] } = await res.json() pageToken = json.nextPageToken for (const file of json.files) { items.push( ...(await handleDocObjectRecursively({ doc: file, token, signal })), ) } } while (pageToken) return items } const onPicked = async (picked: google.picker.ResponseObject) => { if (picked.action !== google.picker.Action.PICKED) return try { onLoadingChange(true) const results: PickedDriveItem[] = [] for (const doc of picked.docs) { results.push( ...(await handleDocObjectRecursively({ doc, token, signal })), ) } onFilesPicked(results, token) } catch (err) { onError(err) } finally { onLoadingChange(false) } } const picker = new google.picker.PickerBuilder() .enableFeature(google.picker.Feature.NAV_HIDDEN) .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) .setDeveloperKey(apiKey) .setAppId(appId) .setOAuthToken(token) .addView( new google.picker.DocsView(google.picker.ViewId.DOCS) .setIncludeFolders(true) // Note: setEnableDrives doesn't seem to work // .setEnableDrives(true) .setSelectFolderEnabled(true) .setMode(google.picker.DocsViewMode.LIST), ) // NOTE: photos is broken and results in an error being returned from Google // I think it's the old Picasa photos // .addView(google.picker.ViewId.PHOTOS) .setCallback(onPicked) .build() picker.setVisible(true) signal?.addEventListener('abort', () => picker.dispose()) } export async function showPhotosPicker({ token, pickingSession, onPickingSessionChange, signal, }: { token: string pickingSession: PickingSession | undefined onPickingSessionChange: (ps: PickingSession) => void signal: AbortSignal | undefined }): Promise<void> { // https://developers.google.com/photos/picker/guides/get-started-picker const headers = getAuthHeader(token) let newPickingSession = pickingSession if (newPickingSession == null) { const createSessionResponse = await fetch( 'https://photospicker.googleapis.com/v1/sessions', { method: 'post', headers, signal }, ) if (createSessionResponse.status === 401) { const resp = await createSessionResponse.json() if (resp.error?.status === 'UNAUTHENTICATED') { throw new InvalidTokenError() } } if (!createSessionResponse.ok) { throw new Error('Failed to create a session') } newPickingSession = (await createSessionResponse.json()) as PickingSession onPickingSessionChange(newPickingSession) } const w = window.open(newPickingSession.pickerUri) signal?.addEventListener('abort', () => w?.close()) } async function resolvePickedPhotos({ accessToken, pickingSession, signal, }: { accessToken: string pickingSession: PickingSession signal: AbortSignal }) { const headers = getAuthHeader(accessToken) let pageToken: string | undefined let mediaItems: MediaItem[] = [] do { const pageSize = 100 const response = await fetch( `https://photospicker.googleapis.com/v1/mediaItems?${new URLSearchParams({ sessionId: pickingSession.id, pageSize: String(pageSize), ...(pageToken && { pageToken }) }).toString()}`, { headers, signal }, ) if (!response.ok) throw new Error('Failed to get a media items') const { mediaItems: batchMediaItems, nextPageToken, }: { mediaItems: MediaItem[]; nextPageToken?: string } = await response.json() pageToken = nextPageToken mediaItems.push(...batchMediaItems) } while (pageToken) // Filter out items that aren't fully processed or ready mediaItems = mediaItems.flatMap((i) => i.type === 'PHOTO' || (i.type === 'VIDEO' && i.mediaFile.mediaFileMetadata.videoMetadata.processingStatus === 'READY') ? [i] : [], ) // Transform media items into picked items with appropriate metadata return mediaItems.map((mediaItem) => { const { id, type, mediaFile: { mimeType, filename, baseUrl }, } = mediaItem return { platform: 'photos' as const, id, mimeType, // we want the original resolution, so we don't append any parameter to the baseUrl // https://developers.google.com/photos/library/guides/access-media-items#base-urls url: type === 'VIDEO' ? `${baseUrl}=dv` : `${baseUrl}=d`, // dv to download video, d to get original image (non cropped) name: filename, metadata: { // Note that metadata keys `filename` and `type` have special meanings in Companion // and should not be overridden googlePhotosFileType: mediaItem.type, createTime: mediaItem.createTime, width: mediaItem.mediaFile.mediaFileMetadata.width, height: mediaItem.mediaFile.mediaFileMetadata.height, ...(mediaItem.type === 'PHOTO' && { cameraMake: mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.cameraMake, cameraModel: mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.cameraModel, focalLength: mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.focalLength, apertureFNumber: mediaItem.mediaFile.mediaFileMetadata.photoMetadata ?.apertureFNumber, isoEquivalent: mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.isoEquivalent, exposureTime: mediaItem.mediaFile.mediaFileMetadata.photoMetadata?.exposureTime, }), ...(mediaItem.type === 'VIDEO' && { cameraMake: mediaItem.mediaFile.mediaFileMetadata.videoMetadata.cameraMake, cameraModel: mediaItem.mediaFile.mediaFileMetadata.videoMetadata.cameraModel, fps: mediaItem.mediaFile.mediaFileMetadata.videoMetadata.fps, processingStatus: mediaItem.mediaFile.mediaFileMetadata.videoMetadata .processingStatus, }), }, } }) } export async function pollPickingSession({ pickingSessionRef, accessTokenRef, signal, onFilesPicked, onError, }: { pickingSessionRef: MutableRef<PickingSession | undefined> accessTokenRef: MutableRef<string | null | undefined> signal: AbortSignal onFilesPicked: (files: PickedItem[], accessToken: string) => void onError: (err: unknown) => void }): Promise<void> { // if we have an active session, poll it until it either times out, or the user selects some photos. // Note that the user can also just close the page, but we get no indication of that from Google when polling, // so we just have to continue polling in the background, so we can react to it // in case the user opens the photo selector again. Hence the infinite for loop for (let interval = 1; ; ) { try { if (pickingSessionRef.current != null) { interval = parseFloat( pickingSessionRef.current.pollingConfig.pollInterval, ) } else { interval = 1 } await Promise.race([ new Promise((resolve) => setTimeout(resolve, interval * 1000)), new Promise((_resolve, reject) => { signal.addEventListener('abort', reject) }), ]) signal.throwIfAborted() const accessToken = accessTokenRef.current const pickingSession = pickingSessionRef.current if (pickingSession != null && accessToken != null) { const headers = getAuthHeader(accessToken) // https://developers.google.com/photos/picker/reference/rest/v1/sessions const response = await fetch( `https://photospicker.googleapis.com/v1/sessions/${encodeURIComponent(pickingSession.id)}`, { headers, signal }, ) if (!response.ok) throw new Error('Failed to get session') const json: PickingSession = await response.json() if (json.mediaItemsSet) { // console.log('User picked!', json) const resolvedPhotos = await resolvePickedPhotos({ accessToken, pickingSession, signal, }) pickingSessionRef.current = undefined onFilesPicked(resolvedPhotos, accessToken) } if (pickingSession.pollingConfig.timeoutIn === '0s') { pickingSessionRef.current = undefined } } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { return } // just report the error and continue polling onError(err) } } }