Stackedit/src/services/providers/helpers/googleHelper.js
2022-11-13 15:16:46 +08:00

703 lines
22 KiB
JavaScript

import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store';
import userSvc from '../../userSvc';
import badgeSvc from '../../badgeSvc';
const appsDomain = null;
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (tokens expire after 1h)
const driveAppDataScopes = ['https://www.googleapis.com/auth/drive.appdata'];
const getDriveScopes = token => [token.driveFullAccess
? 'https://www.googleapis.com/auth/drive'
: 'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive.install'];
const bloggerScopes = ['https://www.googleapis.com/auth/blogger'];
const photosScopes = ['https://www.googleapis.com/auth/photos'];
const checkIdToken = (idToken) => {
try {
const token = idToken.split('.');
const payload = JSON.parse(utils.decodeBase64(token[1]));
const clientId = store.getters['data/serverConf'].googleClientId;
return payload.aud === clientId && Date.now() + tokenExpirationMargin < payload.exp * 1000;
} catch (e) {
return false;
}
};
let driveState;
if (utils.queryParams.providerId === 'googleDrive') {
try {
driveState = JSON.parse(utils.queryParams.state);
} catch (e) {
// Ignore
}
}
/**
* https://developers.google.com/people/api/rest/v1/people/get
*/
const getUser = async (sub, token) => {
const apiKey = store.getters['data/serverConf'].googleApiKey;
const url = `https://people.googleapis.com/v1/people/${sub}?personFields=names,photos&key=${apiKey}`;
const { body } = await networkSvc.request(sub === 'me' && token
? {
method: 'GET',
url,
headers: {
Authorization: `Bearer ${token.accessToken}`,
},
}
: {
method: 'GET',
url,
}, true);
return body;
};
const subPrefix = 'go';
userSvc.setInfoResolver('google', subPrefix, async (sub) => {
try {
const googleToken = Object.values(store.getters['data/googleTokensBySub'])[0];
const body = await getUser(sub, googleToken);
const name = (body.names && body.names[0]) || {};
const photo = (body.photos && body.photos[0]) || {};
return {
id: `${subPrefix}:${sub}`,
name: name.displayName,
imageUrl: (photo.url || '').replace(/\bsz?=\d+$/, 'sz=40'),
};
} catch (err) {
if (err.status !== 404) {
throw new Error('RETRY');
}
throw err;
}
});
export default {
subPrefix,
folderMimeType: 'application/vnd.google-apps.folder',
driveState,
driveActionFolder: null,
driveActionFiles: [],
async $request(token, options) {
try {
return (await networkSvc.request({
...options,
headers: {
...options.headers || {},
Authorization: `Bearer ${token.accessToken}`,
},
}, true)).body;
} catch (err) {
const { reason } = (((err.body || {}).error || {}).errors || [])[0] || {};
if (reason === 'authError') {
// Mark the token as revoked and get a new one
store.dispatch('data/addGoogleToken', {
...token,
expiresOn: 0,
});
// Refresh token and retry
const refreshedToken = await this.refreshToken(token, token.scopes);
return this.$request(refreshedToken, options);
}
throw err;
}
},
/**
* https://developers.google.com/identity/protocols/OpenIDConnect
*/
async startOauth2(scopes, sub = null, silent = false) {
await networkSvc.getServerConf();
const clientId = store.getters['data/serverConf'].googleClientId;
// Get an OAuth2 code
const { accessToken, expiresIn, idToken } = await networkSvc.startOauth2(
'https://accounts.google.com/o/oauth2/v2/auth',
{
client_id: clientId,
response_type: 'token id_token',
scope: ['openid', 'profile', ...scopes].join(' '),
hd: appsDomain,
login_hint: sub,
prompt: silent ? 'none' : null,
nonce: utils.uid(),
},
silent,
);
// Call the token info endpoint
const { body } = await networkSvc.request({
method: 'POST',
url: 'https://www.googleapis.com/oauth2/v3/tokeninfo',
params: {
access_token: accessToken,
},
}, true);
// Check the returned client ID consistency
if (body.aud !== clientId) {
throw new Error('Client ID inconsistent.');
}
// Check the returned sub consistency
if (sub && `${body.sub}` !== sub) {
throw new Error('Google account ID not expected.');
}
// Build token object including scopes and sub
const existingToken = store.getters['data/googleTokensBySub'][body.sub];
const token = {
scopes,
accessToken,
expiresOn: Date.now() + (expiresIn * 1000),
idToken,
sub: body.sub,
name: (existingToken || {}).name || 'Someone',
isLogin: !store.getters['workspace/mainWorkspaceToken'] &&
scopes.includes('https://www.googleapis.com/auth/drive.appdata'),
isSponsor: false,
isDrive: scopes.includes('https://www.googleapis.com/auth/drive') ||
scopes.includes('https://www.googleapis.com/auth/drive.file'),
isBlogger: scopes.includes('https://www.googleapis.com/auth/blogger'),
isPhotos: scopes.includes('https://www.googleapis.com/auth/photos'),
driveFullAccess: scopes.includes('https://www.googleapis.com/auth/drive'),
};
// Call the user info endpoint
const user = await getUser('me', token);
const userId = user.resourceName.split('/')[1];
const name = user.names[0] || {};
const photo = user.photos[0] || {};
if (name.displayName) {
token.name = name.displayName;
}
userSvc.addUserInfo({
id: `${subPrefix}:${userId}`,
name: name.displayName,
imageUrl: (photo.url || '').replace(/\bsz?=\d+$/, 'sz=40'),
});
if (existingToken) {
// We probably retrieved a new token with restricted scopes.
// That's no problem, token will be refreshed later with merged scopes.
// Restore flags
Object.assign(token, {
isLogin: existingToken.isLogin || token.isLogin,
isSponsor: existingToken.isSponsor,
isDrive: existingToken.isDrive || token.isDrive,
isBlogger: existingToken.isBlogger || token.isBlogger,
isPhotos: existingToken.isPhotos || token.isPhotos,
driveFullAccess: existingToken.driveFullAccess || token.driveFullAccess,
});
}
if (token.isLogin) {
try {
const res = await networkSvc.request({
method: 'GET',
url: 'userInfo',
params: {
idToken: token.idToken,
},
});
token.isSponsor = res.body.sponsorUntil > Date.now();
if (token.isSponsor) {
badgeSvc.addBadge('sponsor');
}
} catch (err) {
// Ignore
}
}
// Add token to google tokens
await store.dispatch('data/addGoogleToken', token);
return token;
},
async refreshToken(token, scopes = []) {
const { sub } = token;
const lastToken = store.getters['data/googleTokensBySub'][sub];
const mergedScopes = [...new Set([
...scopes,
...lastToken.scopes,
])];
if (
// If we already have permissions for the requested scopes
mergedScopes.length === lastToken.scopes.length &&
// And lastToken is not expired
lastToken.expiresOn > Date.now() + tokenExpirationMargin &&
// And in case of a login token, ID token is still valid
(!lastToken.isLogin || checkIdToken(lastToken.idToken))
) {
return lastToken;
}
// New scopes are requested or existing token is about to expire.
// Try to get a new token in background
try {
return await this.startOauth2(mergedScopes, sub, true);
} catch (err) {
// If it fails try to popup a window
if (store.state.offline) {
throw err;
}
await store.dispatch('modal/open', {
type: 'providerRedirection',
name: 'Google',
});
return this.startOauth2(mergedScopes, sub);
}
},
signin() {
return this.startOauth2(driveAppDataScopes);
},
async addDriveAccount(fullAccess = false, sub = null) {
const token = await this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess }), sub);
badgeSvc.addBadge('addGoogleDriveAccount');
return token;
},
async addBloggerAccount() {
const token = await this.startOauth2(bloggerScopes);
badgeSvc.addBadge('addBloggerAccount');
return token;
},
async addPhotosAccount() {
const token = await this.startOauth2(photosScopes);
badgeSvc.addBadge('addGooglePhotosAccount');
return token;
},
/**
* https://developers.google.com/drive/v3/reference/files/create
* https://developers.google.com/drive/v3/reference/files/update
* https://developers.google.com/drive/v3/web/simple-upload
*/
async $uploadFile({
refreshedToken,
name,
parents,
appProperties,
media = null,
mediaType = null,
fileId = null,
oldParents = null,
ifNotTooLate = cb => cb(),
}) {
// Refreshing a token can take a while if an oauth window pops up, make sure it's not too late
return ifNotTooLate(() => {
const options = {
method: 'POST',
url: 'https://www.googleapis.com/drive/v3/files',
};
const params = {
supportsTeamDrives: true,
};
const metadata = { name, appProperties };
if (fileId) {
options.method = 'PATCH';
options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`;
if (parents && oldParents) {
params.addParents = parents
.filter(parent => !oldParents.includes(parent))
.join(',');
params.removeParents = oldParents
.filter(parent => !parents.includes(parent))
.join(',');
}
} else if (parents) {
metadata.parents = parents;
}
if (media) {
const boundary = `-------${utils.uid()}`;
const delimiter = `\r\n--${boundary}\r\n`;
const closeDelimiter = `\r\n--${boundary}--`;
let multipartRequestBody = '';
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n';
multipartRequestBody += JSON.stringify(metadata);
multipartRequestBody += delimiter;
multipartRequestBody += `Content-Type: ${mediaType || 'text/plain'}; charset=UTF-8\r\n\r\n`;
multipartRequestBody += media;
multipartRequestBody += closeDelimiter;
options.url = options.url.replace(
'https://www.googleapis.com/',
'https://www.googleapis.com/upload/',
);
return this.$request(refreshedToken, {
...options,
params: {
...params,
uploadType: 'multipart',
},
headers: {
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
},
body: multipartRequestBody,
});
}
if (mediaType) {
metadata.mimeType = mediaType;
}
return this.$request(refreshedToken, {
...options,
body: metadata,
params,
});
});
},
async uploadFile({
token,
name,
parents,
appProperties,
media,
mediaType,
fileId,
oldParents,
ifNotTooLate,
}) {
const refreshedToken = await this.refreshToken(token, getDriveScopes(token));
return this.$uploadFile({
refreshedToken,
name,
parents,
appProperties,
media,
mediaType,
fileId,
oldParents,
ifNotTooLate,
});
},
async uploadAppDataFile({
token,
name,
media,
fileId,
ifNotTooLate,
}) {
const refreshedToken = await this.refreshToken(token, driveAppDataScopes);
return this.$uploadFile({
refreshedToken,
name,
parents: ['appDataFolder'],
media,
fileId,
ifNotTooLate,
});
},
/**
* https://developers.google.com/drive/v3/reference/files/get
*/
async getFile(token, id) {
const refreshedToken = await this.refreshToken(token, getDriveScopes(token));
return this.$request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}`,
params: {
fields: 'id,name,mimeType,appProperties,teamDriveId',
supportsTeamDrives: true,
},
});
},
/**
* https://developers.google.com/drive/v3/web/manage-downloads
*/
async $downloadFile(refreshedToken, id) {
return this.$request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`,
raw: true,
});
},
async downloadFile(token, id) {
const refreshedToken = await this.refreshToken(token, getDriveScopes(token));
return this.$downloadFile(refreshedToken, id);
},
async downloadAppDataFile(token, id) {
const refreshedToken = await this.refreshToken(token, driveAppDataScopes);
return this.$downloadFile(refreshedToken, id);
},
/**
* https://developers.google.com/drive/v3/reference/files/delete
*/
async $removeFile(refreshedToken, id, ifNotTooLate = cb => cb()) {
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late
return ifNotTooLate(() => this.$request(refreshedToken, {
method: 'DELETE',
url: `https://www.googleapis.com/drive/v3/files/${id}`,
params: {
supportsTeamDrives: true,
},
}));
},
async removeFile(token, id, ifNotTooLate) {
const refreshedToken = await this.refreshToken(token, getDriveScopes(token));
return this.$removeFile(refreshedToken, id, ifNotTooLate);
},
async removeAppDataFile(token, id, ifNotTooLate = cb => cb()) {
const refreshedToken = await this.refreshToken(token, driveAppDataScopes);
return this.$removeFile(refreshedToken, id, ifNotTooLate);
},
/**
* https://developers.google.com/drive/v3/reference/revisions/list
*/
async $getFileRevisions(refreshedToken, id) {
const allRevisions = [];
const getPage = async (pageToken) => {
const { revisions, nextPageToken } = await this.$request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}/revisions`,
params: {
pageToken,
pageSize: 1000,
fields: 'nextPageToken,revisions(id,modifiedTime,lastModifyingUser/permissionId,lastModifyingUser/displayName,lastModifyingUser/photoLink)',
},
});
revisions.forEach((revision) => {
userSvc.addUserInfo({
id: `${subPrefix}:${revision.lastModifyingUser.permissionId}`,
name: revision.lastModifyingUser.displayName,
imageUrl: revision.lastModifyingUser.photoLink || '',
});
allRevisions.push(revision);
});
if (nextPageToken) {
return getPage(nextPageToken);
}
return allRevisions;
};
return getPage();
},
async getFileRevisions(token, id) {
const refreshedToken = await this.refreshToken(token, getDriveScopes(token));
return this.$getFileRevisions(refreshedToken, id);
},
async getAppDataFileRevisions(token, id) {
const refreshedToken = await this.refreshToken(token, driveAppDataScopes);
return this.$getFileRevisions(refreshedToken, id);
},
/**
* https://developers.google.com/drive/v3/reference/revisions/get
*/
async $downloadFileRevision(refreshedToken, id, revisionId) {
return this.$request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}/revisions/${revisionId}?alt=media`,
raw: true,
});
},
async downloadFileRevision(token, fileId, revisionId) {
const refreshedToken = await this.refreshToken(token, getDriveScopes(token));
return this.$downloadFileRevision(refreshedToken, fileId, revisionId);
},
async downloadAppDataFileRevision(token, fileId, revisionId) {
const refreshedToken = await this.refreshToken(token, driveAppDataScopes);
return this.$downloadFileRevision(refreshedToken, fileId, revisionId);
},
/**
* https://developers.google.com/drive/v3/reference/changes/list
*/
async getChanges(token, startPageToken, isAppData, teamDriveId = null) {
const result = {
changes: [],
};
let fileFields = 'file/name';
if (!isAppData) {
fileFields += ',file/parents,file/mimeType,file/appProperties';
}
const refreshedToken = await this.refreshToken(
token,
isAppData ? driveAppDataScopes : getDriveScopes(token),
);
const getPage = async (pageToken = '1') => {
const { changes, nextPageToken, newStartPageToken } = await this.$request(refreshedToken, {
method: 'GET',
url: 'https://www.googleapis.com/drive/v3/changes',
params: {
pageToken,
spaces: isAppData ? 'appDataFolder' : 'drive',
pageSize: 1000,
fields: `nextPageToken,newStartPageToken,changes(fileId,${fileFields})`,
supportsTeamDrives: true,
includeTeamDriveItems: !!teamDriveId,
teamDriveId,
},
});
result.changes = [...result.changes, ...changes.filter(item => item.fileId)];
if (nextPageToken) {
return getPage(nextPageToken);
}
result.startPageToken = newStartPageToken;
return result;
};
return getPage(startPageToken);
},
/**
* https://developers.google.com/blogger/docs/3.0/reference/blogs/getByUrl
* https://developers.google.com/blogger/docs/3.0/reference/posts/insert
* https://developers.google.com/blogger/docs/3.0/reference/posts/update
*/
async uploadBlogger({
token,
blogUrl,
blogId,
postId,
title,
content,
labels,
isDraft,
published,
isPage,
}) {
const refreshedToken = await this.refreshToken(token, bloggerScopes);
// Get the blog ID
const blog = { id: blogId };
if (!blog.id) {
blog.id = (await this.$request(refreshedToken, {
url: 'https://www.googleapis.com/blogger/v3/blogs/byurl',
params: {
url: blogUrl,
},
})).id;
}
// Create/update the post/page
const path = isPage ? 'pages' : 'posts';
let options = {
method: 'POST',
url: `https://www.googleapis.com/blogger/v3/blogs/${blog.id}/${path}/`,
body: {
kind: isPage ? 'blogger#page' : 'blogger#post',
blog,
title,
content,
},
};
if (labels) {
options.body.labels = labels;
}
if (published) {
options.body.published = published.toISOString();
}
// If it's an update
if (postId) {
options.method = 'PUT';
options.url += postId;
options.body.id = postId;
}
const post = await this.$request(refreshedToken, options);
if (isPage) {
return post;
}
// Revert/publish post
options = {
method: 'POST',
url: `https://www.googleapis.com/blogger/v3/blogs/${post.blog.id}/posts/${post.id}/`,
params: {},
};
if (isDraft) {
options.url += 'revert';
} else {
options.url += 'publish';
if (published) {
options.params.publishDate = published.toISOString();
}
}
return this.$request(refreshedToken, options);
},
/**
* https://developers.google.com/picker/docs/
*/
async openPicker(token, type = 'doc') {
const scopes = type === 'img' ? photosScopes : getDriveScopes(token);
if (!window.google) {
await networkSvc.loadScript('https://apis.google.com/js/api.js');
await new Promise((resolve, reject) => window.gapi.load('picker', {
callback: resolve,
onerror: reject,
timeout: 30000,
ontimeout: reject,
}));
}
const refreshedToken = await this.refreshToken(token, scopes);
const { google } = window;
return new Promise((resolve) => {
let picker;
const pickerBuilder = new google.picker.PickerBuilder()
.setOAuthToken(refreshedToken.accessToken)
.enableFeature(google.picker.Feature.SUPPORT_TEAM_DRIVES)
.hideTitleBar()
.setCallback((data) => {
switch (data[google.picker.Response.ACTION]) {
case google.picker.Action.PICKED:
case google.picker.Action.CANCEL:
resolve(data.docs || []);
picker.dispose();
break;
default:
}
});
switch (type) {
default:
case 'doc': {
const mimeTypes = [
'text/plain',
'text/x-markdown',
'application/octet-stream',
].join(',');
const view = new google.picker.DocsView(google.picker.ViewId.DOCS);
view.setMimeTypes(mimeTypes);
pickerBuilder.addView(view);
const teamDriveView = new google.picker.DocsView(google.picker.ViewId.DOCS);
teamDriveView.setMimeTypes(mimeTypes);
teamDriveView.setEnableTeamDrives(true);
pickerBuilder.addView(teamDriveView);
pickerBuilder.enableFeature(google.picker.Feature.MULTISELECT_ENABLED);
pickerBuilder.enableFeature(google.picker.Feature.SUPPORT_TEAM_DRIVES);
break;
}
case 'folder': {
const folderView = new google.picker.DocsView(google.picker.ViewId.FOLDERS);
folderView.setSelectFolderEnabled(true);
folderView.setMimeTypes(this.folderMimeType);
pickerBuilder.addView(folderView);
const teamDriveView = new google.picker.DocsView(google.picker.ViewId.FOLDERS);
teamDriveView.setSelectFolderEnabled(true);
teamDriveView.setEnableTeamDrives(true);
teamDriveView.setMimeTypes(this.folderMimeType);
pickerBuilder.addView(teamDriveView);
break;
}
case 'img': {
const view = new google.picker.PhotosView();
view.setType('highlights');
pickerBuilder.addView(view);
pickerBuilder.addView(google.picker.ViewId.PHOTO_UPLOAD);
break;
}
}
picker = pickerBuilder.build();
picker.setVisible(true);
});
},
};