@uppy/provider-views
Version:
View library for Uppy remote provider plugins.
245 lines (244 loc) • 11.6 kB
JavaScript
const getAuthHeader = (token) => ({
authorization: `Bearer ${token}`,
});
const injectedScripts = new Set();
let driveApiLoaded = false;
// https://stackoverflow.com/a/39008859/6519037
async function injectScript(src) {
if (injectedScripts.has(src))
return;
await new Promise((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) {
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((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, signal) {
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, }) {
const response = await new Promise((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) {
await new Promise((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, }) {
// 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) => {
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, }) {
// 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());
onPickingSessionChange(newPickingSession);
}
const w = window.open(newPickingSession.pickerUri);
signal?.addEventListener('abort', () => w?.close());
}
async function resolvePickedPhotos({ accessToken, pickingSession, signal, }) {
const headers = getAuthHeader(accessToken);
let pageToken;
let mediaItems = [];
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, } = 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',
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, }) {
// 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 = 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);
}
}
}