@uppy/provider-views
Version:
View library for Uppy remote provider plugins.
428 lines (381 loc) • 12.3 kB
text/typescript
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)
}
}
}