@uppy/companion
Version:
OAuth helper and remote fetcher for Uppy's (https://uppy.io) extensible file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Dropbox and Google Drive, S3 and more :dog:
171 lines (170 loc) • 7.84 kB
JavaScript
import got from 'got';
import { MAX_AGE_REFRESH_TOKEN } from '../../../helpers/jwt.js';
import { prepareStream } from '../../../helpers/utils.js';
import logger from '../../../logger.js';
import { ProviderAuthError } from '../../error.js';
import Provider from '../../Provider.js';
import { withGoogleErrorHandling } from '../../providerErrors.js';
import { logout, refreshToken } from '../index.js';
import { adaptData, getGsuiteExportType, isGsuiteFile, isShortcut, VIRTUAL_SHARED_DIR, } from './adapter.js';
// For testing refresh token:
// first run a download with mockAccessTokenExpiredError = true
// then when you want to test expiry, set to mockAccessTokenExpiredError to the logged access token
// This will trigger companion/nodemon to restart, and it will respond with a simulated invalid token response
const mockAccessTokenExpiredError = undefined;
// const mockAccessTokenExpiredError = true
// const mockAccessTokenExpiredError = ''
const DRIVE_FILE_FIELDS = 'kind,id,imageMediaMetadata,name,mimeType,ownedByMe,size,modifiedTime,iconLink,thumbnailLink,teamDriveId,videoMediaMetadata,exportLinks,shortcutDetails(targetId,targetMimeType)';
const DRIVE_FILES_FIELDS = `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS})`;
// using wildcard to get all 'drive' fields because specifying fields seems no to work for the /drives endpoint
const SHARED_DRIVE_FIELDS = '*';
const getClient = ({ token }) => got.extend({
prefixUrl: 'https://www.googleapis.com/drive/v3',
headers: {
authorization: `Bearer ${token}`,
},
});
async function getStats({ id, token }) {
const client = getClient({ token });
const getStatsInner = async (statsOfId) => client
.get(`files/${encodeURIComponent(statsOfId)}`, {
searchParams: { fields: DRIVE_FILE_FIELDS, supportsAllDrives: true },
responseType: 'json',
})
.json();
const stats = await getStatsInner(id);
// If it is a shortcut, we need to get stats again on the target
if (isShortcut(stats.mimeType))
return getStatsInner(stats.shortcutDetails.targetId);
return stats;
}
export async function streamGoogleFile({ token, id: idIn }) {
const client = getClient({ token });
const { mimeType, id, exportLinks } = await getStats({ id: idIn, token });
let stream;
if (isGsuiteFile(mimeType)) {
const mimeType2 = getGsuiteExportType(mimeType);
logger.info(`calling google file export for ${id} to ${mimeType2}`, 'provider.drive.export');
// GSuite files exported with large converted size results in error using standard export method.
// Error message: "This file is too large to be exported.".
// Issue logged in Google APIs: https://github.com/googleapis/google-api-nodejs-client/issues/3446
// Implemented based on the answer from StackOverflow: https://stackoverflow.com/a/59168288
const mimeTypeExportLink = exportLinks?.[mimeType2];
if (mimeTypeExportLink) {
stream = got.stream.get(mimeTypeExportLink, {
headers: {
authorization: `Bearer ${token}`,
},
responseType: 'json',
});
}
else {
stream = client.stream.get(`files/${encodeURIComponent(id)}/export`, {
searchParams: { supportsAllDrives: true, mimeType: mimeType2 },
responseType: 'json',
});
}
}
else {
stream = client.stream.get(`files/${encodeURIComponent(id)}`, {
searchParams: { alt: 'media', supportsAllDrives: true },
responseType: 'json',
});
}
const { size } = await prepareStream(stream);
return { stream, size };
}
/**
* Adapter for API https://developers.google.com/drive/api/v3/
*/
export class Drive extends Provider {
static get oauthProvider() {
return 'googledrive';
}
static get authStateExpiry() {
return MAX_AGE_REFRESH_TOKEN;
}
async list(options) {
return withGoogleErrorHandling(Drive.oauthProvider, 'provider.drive.list.error', async () => {
const directory = options.directory || 'root';
const query = options.query || {};
const { providerUserSession: { accessToken: token }, } = options;
const isRoot = directory === 'root';
const isVirtualSharedDirRoot = directory === VIRTUAL_SHARED_DIR;
const client = getClient({ token });
async function fetchSharedDrives(pageToken = null) {
const shouldListSharedDrives = isRoot && !query.cursor;
if (!shouldListSharedDrives)
return undefined;
const response = await client
.get('drives', {
searchParams: {
fields: SHARED_DRIVE_FIELDS,
pageToken,
pageSize: 100,
},
responseType: 'json',
})
.json();
const { nextPageToken } = response;
if (nextPageToken) {
const nextResponse = await fetchSharedDrives(nextPageToken);
if (!nextResponse)
return response;
return {
...nextResponse,
drives: [...response.drives, ...nextResponse.drives],
};
}
return response;
}
async function fetchFiles() {
// Shared with me items in root don't have any parents
const q = isVirtualSharedDirRoot
? `sharedWithMe and trashed=false`
: `('${directory}' in parents) and trashed=false`;
const searchParams = {
fields: DRIVE_FILES_FIELDS,
pageToken: query.cursor,
q,
// We can only do a page size of 1000 because we do not request permissions in DRIVE_FILES_FIELDS.
// Otherwise we are limited to 100. Instead we get the user info from `this.user()`
pageSize: 1000,
orderBy: 'folder,name',
includeItemsFromAllDrives: true,
supportsAllDrives: true,
};
return client
.get('files', { searchParams, responseType: 'json' })
.json();
}
async function fetchAbout() {
const searchParams = { fields: 'user' };
return client
.get('about', { searchParams, responseType: 'json' })
.json();
}
const [sharedDrives, filesResponse, about] = await Promise.all([
fetchSharedDrives(),
fetchFiles(),
fetchAbout(),
]);
return adaptData(filesResponse, sharedDrives, directory, query, isRoot && !query.cursor, // we can only show it on the first page request, or else we will have duplicates of it
about);
});
}
async download({ id, providerUserSession: { accessToken: token } }) {
if (mockAccessTokenExpiredError != null) {
logger.warn(`Access token: ${token}`);
if (mockAccessTokenExpiredError === token) {
logger.warn('Mocking expired access token!');
throw new ProviderAuthError();
}
}
return withGoogleErrorHandling(Drive.oauthProvider, 'provider.drive.download.error', async () => {
return streamGoogleFile({ token, id });
});
}
}
Drive.prototype.logout = logout;
Drive.prototype.refreshToken = refreshToken;