UNPKG

@uppy/provider-views

Version:

View library for Uppy remote provider plugins.

428 lines (381 loc) 12.3 kB
import { type MutableRef } from 'preact/hooks' // https://developers.google.com/photos/picker/reference/rest/v1/mediaItems export interface MediaItemBase { id: string createTime: string } interface MediaFileMetadataBase { width: number height: number cameraMake: string cameraModel: string } interface MediaFileBase { baseUrl: string mimeType: string filename: string } export interface VideoMediaItem extends MediaItemBase { type: 'VIDEO' mediaFile: MediaFileBase & { mediaFileMetadata: MediaFileMetadataBase & { videoMetadata: { fps: number processingStatus: 'UNSPECIFIED' | 'PROCESSING' | 'READY' | 'FAILED' } } } } export interface PhotoMediaItem extends MediaItemBase { type: 'PHOTO' mediaFile: MediaFileBase & { mediaFileMetadata: MediaFileMetadataBase & { photoMetadata: { focalLength: number apertureFNumber: number isoEquivalent: number exposureTime: string } } } } export interface UnspecifiedMediaItem extends MediaItemBase { type: 'TYPE_UNSPECIFIED' mediaFile: MediaFileBase } export type MediaItem = VideoMediaItem | PhotoMediaItem | UnspecifiedMediaItem // 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 } 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, }: { token: string apiKey: string appId: string onFilesPicked: (files: PickedItem[], accessToken: string) => void signal: AbortSignal | undefined }): 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() } const onPicked = (picked: google.picker.ResponseObject) => { if (picked.action === google.picker.Action.PICKED) { // console.log('Picker response', JSON.stringify(picked, null, 2)); onFilesPicked( picked['docs'].map((doc) => ({ platform: 'drive', id: doc['id'], name: doc['name'], mimeType: doc['mimeType'], })), token, ) } } 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(false) .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) }).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) // todo show alert instead about invalid picked files? mediaItems = mediaItems.flatMap((i) => ( i.type === 'PHOTO' || (i.type === 'VIDEO' && i.mediaFile.mediaFileMetadata.videoMetadata.processingStatus === 'READY') ) ? [i] : [], ) return mediaItems.map( ({ id, type, // 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 mediaFile: { mimeType, filename, baseUrl }, }) => ({ platform: 'photos' as const, id, mimeType, url: type === 'VIDEO' ? `${baseUrl}=dv` : baseUrl, // dv to download video name: filename, }), ) } 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, }) // eslint-disable-next-line no-param-reassign pickingSessionRef.current = undefined onFilesPicked(resolvedPhotos, accessToken) } if (pickingSession.pollingConfig.timeoutIn === '0s') { // eslint-disable-next-line no-param-reassign pickingSessionRef.current = undefined } } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { return } // just report the error and continue polling onError(err) } } }