Fixed google authorization popup

This commit is contained in:
benweet 2017-10-02 01:34:48 +01:00
parent 0a39710dac
commit 73cea1879d
29 changed files with 684 additions and 454 deletions

View File

@ -14,6 +14,10 @@ module.exports = {
plugins: [
'html'
],
globals: {
"NODE_ENV": false,
"VERSION": false
},
// check if imports actually resolve
'settings': {
'import/resolver': {

View File

@ -1,4 +1,5 @@
var path = require('path')
var webpack = require('webpack')
var utils = require('./utils')
var config = require('../config')
var vueLoaderConfig = require('./vue-loader.conf')
@ -74,6 +75,9 @@ module.exports = {
plugins: [
new StylelintPlugin({
files: ['**/*.vue', '**/*.scss']
}),
new webpack.DefinePlugin({
VERSION: JSON.stringify(require('../package.json').version)
})
]
}

View File

@ -19,7 +19,7 @@ module.exports = merge(baseWebpackConfig, {
devtool: 'source-map',
plugins: [
new webpack.DefinePlugin({
'process.env': config.dev.env
NODE_ENV: config.dev.env.NODE_ENV
}),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.HotModuleReplacementPlugin(),

View File

@ -28,7 +28,7 @@ var webpackConfig = merge(baseWebpackConfig, {
plugins: [
// http://vuejs.github.io/vue-loader/en/workflow/production.html
new webpack.DefinePlugin({
'process.env': env
NODE_ENV: env.NODE_ENV
}),
new webpack.optimize.UglifyJsPlugin({
compress: {

View File

@ -1,10 +1,10 @@
<template>
<div v-if="ready" class="app" :class="{'app--loading': loading}">
<splash-screen v-if="!ready"></splash-screen>
<div v-else class="app" :class="{'app--loading': loading}">
<layout></layout>
<modal v-if="showModal"></modal>
<notification></notification>
</div>
<div v-else class="app__spash-screen"></div>
</template>
<script>
@ -13,6 +13,7 @@ import { mapState } from 'vuex';
import Layout from './Layout';
import Modal from './Modal';
import Notification from './Notification';
import SplashScreen from './SplashScreen';
// Global directives
Vue.directive('focus', {
@ -26,6 +27,7 @@ export default {
Layout,
Modal,
Notification,
SplashScreen,
},
computed: {
...mapState([

View File

@ -1,6 +1,6 @@
<template>
<div class="layout">
<div class="layout__panel flex flex--row" :class="{'flex--end': styles.showSideBar && !styles.showExplorer}">
<div class="layout__panel flex flex--row" :class="{'flex--end': styles.showSideBar}">
<div class="layout__panel layout__panel--explorer" v-show="styles.showExplorer" :style="{ width: constants.explorerWidth + 'px' }">
<explorer></explorer>
</div>

View File

@ -3,6 +3,7 @@
<file-properties-modal v-if="config.type === 'fileProperties'"></file-properties-modal>
<settings-modal v-else-if="config.type === 'settings'"></settings-modal>
<templates-modal v-else-if="config.type === 'templates'"></templates-modal>
<about-modal v-else-if="config.type === 'about'"></about-modal>
<html-export-modal v-else-if="config.type === 'htmlExport'"></html-export-modal>
<link-modal v-else-if="config.type === 'link'"></link-modal>
<image-modal v-else-if="config.type === 'image'"></image-modal>
@ -42,6 +43,7 @@ import editorEngineSvc from '../services/editorEngineSvc';
import FilePropertiesModal from './modals/FilePropertiesModal';
import SettingsModal from './modals/SettingsModal';
import TemplatesModal from './modals/TemplatesModal';
import AboutModal from './modals/AboutModal';
import HtmlExportModal from './modals/HtmlExportModal';
import LinkModal from './modals/LinkModal';
import ImageModal from './modals/ImageModal';
@ -69,6 +71,7 @@ export default {
FilePropertiesModal,
SettingsModal,
TemplatesModal,
AboutModal,
HtmlExportModal,
LinkModal,
ImageModal,

View File

@ -22,13 +22,13 @@
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in syncLocations" :key="location.id" :href="location.url" target="_blank">
<icon-provider :provider-id="location.providerId"></icon-provider>
</a>
<button class="navigation-bar__button navigation-bar__button--sync button" v-if="isSyncPossible" :disabled="isSyncRequested || offline" @click="requestSync">
<button class="navigation-bar__button navigation-bar__button--sync button" :disabled="!isSyncPossible || isSyncRequested || offline" @click="requestSync">
<icon-sync></icon-sync>
</button>
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in publishLocations" :key="location.id" :href="location.url" target="_blank">
<icon-provider :provider-id="location.providerId"></icon-provider>
</a>
<button class="navigation-bar__button navigation-bar__button--publish button" v-if="publishLocations.length" :disabled="isPublishRequested || offline" @click="requestPublish">
<button class="navigation-bar__button navigation-bar__button--publish button" :disabled="!publishLocations.length || isPublishRequested || offline" @click="requestPublish">
<icon-upload></icon-upload>
</button>
</div>
@ -148,12 +148,12 @@ export default {
'toggleSideBar',
]),
requestSync() {
if (!this.isSyncRequested) {
if (this.isSyncPossible && !this.isSyncRequested) {
syncSvc.requestSync();
}
},
requestPublish() {
if (!this.isPublishRequested) {
if (this.publishLocations.length && !this.isPublishRequested) {
publishSvc.requestPublish();
}
},

View File

@ -90,7 +90,7 @@ export default {
height: 100%;
hr {
margin: 10px;
margin: 10px 15px;
display: none;
}

View File

@ -0,0 +1,22 @@
<template>
<div class="splash-screen">
<div class="splash-screen__inner background-logo"></div>
</div>
</template>
<style lang="scss">
.splash-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 25px;
}
.splash-screen__inner {
margin: 0 auto;
max-width: 600px;
height: 100%;
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<div class="user-image" :style="{'background-image': url}">
</div>
</template>
<script>
import googleHelper from '../services/providers/helpers/googleHelper';
const promised = {};
export default {
props: ['userId'],
computed: {
url() {
const userInfo = this.$store.state.userInfo.itemMap[this.userId];
return userInfo && `url('${userInfo.imageUrl}')`;
},
},
created() {
if (!promised[this.userId] && !this.$store.state.offline) {
promised[this.userId] = true;
googleHelper.getUser(this.userId)
.catch(() => {
promised[this.userId] = false;
});
}
},
};
</script>
<style lang="scss">
.user-image {
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
</style>

View File

@ -90,75 +90,13 @@ textarea {
&:active,
&:focus,
&:hover {
opacity: 0.5;
opacity: 0.33;
background-color: transparent;
cursor: inherit;
cursor: not-allowed;
}
}
}
.form-entry {
margin: 1em 0;
}
.form-entry__label {
display: block;
font-size: 0.9rem;
color: #a0a0a0;
.form-entry--focused & {
color: darken($link-color, 10%);
}
}
.form-entry__field {
border: 1px solid #d8d8d8;
border-radius: $border-radius-base;
position: relative;
overflow: hidden;
.form-entry--focused & {
border-color: $link-color;
}
}
.form-entry__actions {
text-align: right;
margin: 0.25em;
}
.form-entry__button {
width: 38px;
height: 38px;
padding: 6px;
display: inline-block;
background-color: transparent;
opacity: 0.75;
&:active,
&:focus,
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
}
.form-entry__radio,
.form-entry__checkbox {
margin: 0.25em 1em;
input {
margin-right: 0.25em;
}
}
.form-entry__info {
font-size: 0.75em;
opacity: 0.5;
line-height: 1.4;
margin: 0.25em 0;
}
.textfield {
background-color: transparent;
border: 0;
@ -289,3 +227,8 @@ textarea {
background-color: transparent;
}
}
.logo-background {
background: no-repeat center url('../assets/logo.svg');
background-size: contain;
}

View File

@ -5,6 +5,12 @@
<div>Sign in with Google</div>
<span>Back up and sync all your files, folders and settings.</span>
</menu-entry>
<div v-else class="menu-entry flex flex--row flex--align-center">
<div class="menu-entry__icon menu-entry__icon--image">
<user-image :user-id="loginToken.sub"></user-image>
</div>
<span>Signed in as <b>{{loginToken.name}}</b>.</span>
</div>
<hr>
<menu-entry @click.native="setPanel('sync')">
<icon-sync slot="icon"></icon-sync>
@ -49,12 +55,14 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import MenuEntry from './MenuEntry';
import UserImage from '../UserImage';
import googleHelper from '../../services/providers/helpers/googleHelper';
import syncSvc from '../../services/syncSvc';
export default {
components: {
MenuEntry,
UserImage,
},
computed: {
...mapGetters('data', [

View File

@ -10,6 +10,8 @@
</template>
<style lang="scss">
@import '../common/variables.scss';
.menu-entry {
text-align: left;
padding: 10px;
@ -30,4 +32,9 @@
margin-right: 12px;
flex: none;
}
.menu-entry__icon--image {
border-radius: $border-radius-base;
overflow: hidden;
}
</style>

View File

@ -16,13 +16,16 @@
<span>Sign out and clean local data.</span>
</menu-entry>
<hr>
<menu-entry @click.native="about">
<icon-help-circle slot="icon"></icon-help-circle>
<span>About StackEdit</span>
</menu-entry>
<a href="editor" target="_blank" class="menu-entry button flex flex--row flex--align-center">
<div class="menu-entry__icon flex flex--column flex--center">
<icon-alert></icon-alert>
<icon-open-in-new></icon-open-in-new>
</div>
<div class="flex flex--column">
<div>StackEdit v4</div>
<span>Deprecated.</span>
<span>Go back to StackEdit 4</span>
</div>
</a>
</div>
@ -49,6 +52,9 @@ export default {
return this.$store.dispatch('modal/reset')
.then(() => localDbSvc.removeDb());
},
about() {
return this.$store.dispatch('modal/open', 'about');
},
},
};
</script>

View File

@ -0,0 +1,54 @@
<template>
<div class="modal__inner-1 modal__inner-1--about-modal">
<div class="modal__inner-2">
<div class="logo-background"></div>
<div class="app-version">v{{version}} © 2017 Benoit Schweblin</div>
<hr>
<a target="_blank" href="https://github.com/benweet/stackedit/">StackEdit on GitHub</a> /
<a target="_blank" href="https://github.com/benweet/stackedit/issues">issue tracker</a>
<br>
<a target="_blank" href="https://chrome.google.com/webstore/detail/stackedit/iiooodelglhkcpgbajoejffhijaclcdg">Chrome app</a> thanks for your review!
<br>
<a target="_blank" href="https://twitter.com/stackedit/">StackEdit on Twitter</a>
<hr>
<a target="_blank" href="privacy_policy.html">Privacy Policy</a>
<div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
data: () => ({
version: VERSION,
}),
computed: mapGetters('modal', [
'config',
]),
};
</script>
<style lang="scss">
.modal__inner-1--about-modal {
text-align: center;
.logo-background {
height: 75px;
margin-bottom: 0.5rem;
}
.app-version {
font-size: 0.75em;
}
hr {
width: 160px;
max-width: 100%;
margin: 1.5em auto;
}
}
</style>

View File

@ -23,4 +23,67 @@ export default {
</script>
<style lang="scss">
@import '../common/variables.scss';
.form-entry {
margin: 1em 0;
}
.form-entry__label {
display: block;
font-size: 0.9rem;
color: #a0a0a0;
.form-entry--focused & {
color: darken($link-color, 10%);
}
}
.form-entry__field {
border: 1px solid #d8d8d8;
border-radius: $border-radius-base;
position: relative;
overflow: hidden;
.form-entry--focused & {
border-color: $link-color;
}
}
.form-entry__actions {
text-align: right;
margin: 0.25em;
}
.form-entry__button {
width: 38px;
height: 38px;
padding: 6px;
display: inline-block;
background-color: transparent;
opacity: 0.75;
&:active,
&:focus,
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
}
.form-entry__radio,
.form-entry__checkbox {
margin: 0.25em 1em;
input {
margin-right: 0.25em;
}
}
.form-entry__info {
font-size: 0.75em;
opacity: 0.5;
line-height: 1.4;
margin: 0.25em 0;
}
</style>

View File

@ -1,4 +1,5 @@
import Vue from 'vue';
import Provider from './Provider';
import FormatBold from './FormatBold';
import FormatItalic from './FormatItalic';
import FormatQuoteClose from './FormatQuoteClose';
@ -40,9 +41,8 @@ import OpenInNew from './OpenInNew';
import Information from './Information';
import Alert from './Alert';
import SignalOff from './SignalOff';
// Providers
import Provider from './Provider';
Vue.component('iconProvider', Provider);
Vue.component('iconFormatBold', FormatBold);
Vue.component('iconFormatItalic', FormatItalic);
Vue.component('iconFormatQuoteClose', FormatQuoteClose);
@ -84,5 +84,3 @@ Vue.component('iconOpenInNew', OpenInNew);
Vue.component('iconInformation', Information);
Vue.component('iconAlert', Alert);
Vue.component('iconSignalOff', SignalOff);
// Providers
Vue.component('iconProvider', Provider);

View File

@ -6,7 +6,7 @@ import './icons/';
import App from './components/App';
import store from './store';
if (process.env.NODE_ENV === 'production') {
if (NODE_ENV === 'production') {
OfflinePluginRuntime.install();
}

266
src/services/networkSvc.js Normal file
View File

@ -0,0 +1,266 @@
import utils from './utils';
import store from '../store';
const scriptLoadingPromises = Object.create(null);
const oauth2AuthorizationTimeout = 120 * 1000; // 2 minutes
const networkTimeout = 30 * 1000; // 30 sec
let isConnectionDown = false;
export default {
loadScript(url) {
if (!scriptLoadingPromises[url]) {
scriptLoadingPromises[url] = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.onload = resolve;
script.onerror = () => {
scriptLoadingPromises[url] = null;
reject();
};
script.src = url;
document.head.appendChild(script);
});
}
return scriptLoadingPromises[url];
},
startOauth2(url, params = {}, silent = false) {
// Build the authorize URL
const state = utils.uid();
params.state = state;
params.redirect_uri = utils.oauth2RedirectUri;
const authorizeUrl = utils.addQueryParams(url, params);
let iframeElt;
let wnd;
if (silent) {
// Use an iframe as wnd for silent mode
iframeElt = document.createElement('iframe');
iframeElt.style.position = 'absolute';
iframeElt.style.left = '-9999px';
iframeElt.src = authorizeUrl;
document.body.appendChild(iframeElt);
wnd = iframeElt.contentWindow;
} else {
// Open a tab otherwise
wnd = window.open(authorizeUrl);
if (!wnd) {
return Promise.reject('The authorize window was blocked.');
}
}
return new Promise((resolve, reject) => {
let checkClosedInterval;
let closeTimeout;
let msgHandler;
let clean = () => {
clearInterval(checkClosedInterval);
if (!silent && !wnd.closed) {
wnd.close();
}
if (iframeElt) {
document.body.removeChild(iframeElt);
}
clearTimeout(closeTimeout);
window.removeEventListener('message', msgHandler);
clean = () => Promise.resolve(); // Prevent from cleaning several times
return Promise.resolve();
};
if (silent) {
iframeElt.onerror = () => clean()
.then(() => reject('Unknown error.'));
closeTimeout = setTimeout(
() => clean()
.then(() => {
isConnectionDown = true;
store.commit('setOffline', true);
store.commit('updateLastOfflineCheck');
reject('You are offline.');
}),
networkTimeout);
} else {
closeTimeout = setTimeout(
() => clean()
.then(() => reject('Timeout.')),
oauth2AuthorizationTimeout);
}
msgHandler = event => event.source === wnd && event.origin === utils.origin && clean()
.then(() => {
const data = {};
`${event.data}`.slice(1).split('&').forEach((param) => {
const [key, value] = param.split('=').map(decodeURIComponent);
data[key] = value;
});
if (data.error || data.state !== state) {
reject('Could not get required authorization.');
} else {
resolve({
accessToken: data.access_token,
code: data.code,
expiresIn: data.expires_in,
});
}
});
window.addEventListener('message', msgHandler);
if (!silent) {
checkClosedInterval = setInterval(() => wnd.closed && clean()
.then(() => reject('Authorize window was closed.')), 250);
}
});
},
request(configParam, offlineCheck = false) {
let retryAfter = 500; // 500 ms
const maxRetryAfter = 30 * 1000; // 30 sec
const config = Object.assign({}, configParam);
config.timeout = config.timeout || networkTimeout;
config.headers = Object.assign({}, config.headers);
if (config.body && typeof config.body === 'object') {
config.body = JSON.stringify(config.body);
config.headers['Content-Type'] = 'application/json';
}
function parseHeaders(xhr) {
const pairs = xhr.getAllResponseHeaders().trim().split('\n');
return pairs.reduce((headers, header) => {
const split = header.trim().split(':');
const key = split.shift().trim().toLowerCase();
const value = split.join(':').trim();
headers[key] = value;
return headers;
}, {});
}
function isRetriable(err) {
if (err.status === 403) {
const googleReason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason;
return googleReason === 'rateLimitExceeded' || googleReason === 'userRateLimitExceeded';
}
return err.status === 429 || (err.status >= 500 && err.status < 600);
}
const attempt =
() => new Promise((resolve, reject) => {
if (offlineCheck) {
store.commit('updateLastOfflineCheck');
}
const xhr = new window.XMLHttpRequest();
let timeoutId;
xhr.onload = () => {
if (offlineCheck) {
store.commit('setOffline', false);
}
clearTimeout(timeoutId);
const result = {
status: xhr.status,
headers: parseHeaders(xhr),
body: xhr.responseText,
};
if (!config.raw) {
try {
result.body = JSON.parse(result.body);
} catch (e) {
// ignore
}
}
if (result.status >= 200 && result.status < 300) {
resolve(result);
return;
}
reject(result);
};
xhr.onerror = () => {
clearTimeout(timeoutId);
if (offlineCheck) {
store.commit('setOffline', true);
reject('You are offline.');
} else {
reject('Network request failed.');
}
};
timeoutId = setTimeout(() => {
xhr.abort();
if (offlineCheck) {
store.commit('setOffline', true);
reject('You are offline.');
} else {
reject('Network request timeout.');
}
}, config.timeout);
const url = utils.addQueryParams(config.url, config.params);
xhr.open(config.method || 'GET', url);
Object.keys(config.headers).forEach((key) => {
const value = config.headers[key];
if (value) {
xhr.setRequestHeader(key, `${value}`);
}
});
xhr.send(config.body || null);
})
.catch((err) => {
// Try again later in case of retriable error
if (isRetriable(err) && retryAfter < maxRetryAfter) {
return new Promise(
(resolve) => {
setTimeout(resolve, retryAfter);
// Exponential backoff
retryAfter *= 2;
})
.then(attempt);
}
throw err;
});
return attempt();
},
};
function checkOffline() {
const isBrowserOffline = window.navigator.onLine === false;
if (!isBrowserOffline &&
store.state.lastOfflineCheck + networkTimeout + 5000 < Date.now() &&
utils.isUserActive()
) {
store.commit('updateLastOfflineCheck');
new Promise((resolve, reject) => {
const script = document.createElement('script');
let timeout;
const cleaner = (cb, res) => () => {
clearTimeout(timeout);
cb(res);
document.head.removeChild(script);
};
script.onload = cleaner(resolve);
script.onerror = cleaner(reject);
script.src = `https://apis.google.com/js/api.js?${Date.now()}`;
try {
document.head.appendChild(script); // This can fail with bad network
timeout = setTimeout(cleaner(reject), networkTimeout);
} catch (e) {
reject(e);
}
})
.then(() => {
isConnectionDown = false;
}, () => {
isConnectionDown = true;
});
}
const offline = isBrowserOffline || isConnectionDown;
if (store.state.offline !== offline) {
store.commit('setOffline', offline);
if (offline) {
store.dispatch('notification/error', 'You are offline.');
} else {
store.dispatch('notification/info', 'You are back online!');
}
}
}
utils.setInterval(checkOffline, 1000);
window.addEventListener('online', checkOffline);
window.addEventListener('offline', checkOffline);

View File

@ -1,4 +1,4 @@
import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store';
let Dropbox;
@ -10,7 +10,7 @@ const getAppKey = (fullAccess) => {
return 'sw0hlixhr8q1xk0';
};
const request = (token, options, args) => utils.request({
const request = (token, options, args) => networkSvc.request({
...options,
headers: {
...options.headers || {},
@ -23,7 +23,7 @@ const request = (token, options, args) => utils.request({
export default {
startOauth2(fullAccess, sub = null, silent = false) {
return utils.startOauth2(
return networkSvc.startOauth2(
'https://www.dropbox.com/oauth2/authorize', {
client_id: getAppKey(fullAccess),
response_type: 'token',
@ -54,7 +54,7 @@ export default {
if (Dropbox) {
return Promise.resolve();
}
return utils.loadScript('https://www.dropbox.com/static/api/2/dropins.js')
return networkSvc.loadScript('https://www.dropbox.com/static/api/2/dropins.js')
.then(() => {
Dropbox = window.Dropbox;
});

View File

@ -1,4 +1,5 @@
import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store';
let clientId = 'cbf0cf25cfd026be23e1';
@ -7,7 +8,7 @@ if (utils.origin === 'https://stackedit.io') {
}
const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist'];
const request = (token, options) => utils.request({
const request = (token, options) => networkSvc.request({
...options,
headers: {
...options.headers || {},
@ -21,13 +22,13 @@ const request = (token, options) => utils.request({
export default {
startOauth2(scopes, sub = null, silent = false) {
return utils.startOauth2(
return networkSvc.startOauth2(
'https://github.com/login/oauth/authorize', {
client_id: clientId,
scope: scopes.join(' '),
}, silent)
// Exchange code with token
.then(data => utils.request({
.then(data => networkSvc.request({
method: 'GET',
url: 'oauth2/githubToken',
params: {
@ -37,7 +38,7 @@ export default {
})
.then(res => res.body))
// Call the user info endpoint
.then(accessToken => utils.request({
.then(accessToken => networkSvc.request({
method: 'GET',
url: 'https://api.github.com/user',
params: {

View File

@ -1,7 +1,9 @@
import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store';
const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
const apiKey = 'AIzaSyC_M4RA9pY6XmM9pmFxlT59UPMO7aHr9kk';
const appsDomain = null;
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (Google tokens expire after 1h)
let gapi;
@ -17,17 +19,33 @@ const photosScopes = ['https://www.googleapis.com/auth/photos'];
const libraries = ['picker'];
const request = (token, options) => utils.request({
export default {
request(token, options) {
return networkSvc.request({
...options,
headers: {
...options.headers || {},
Authorization: `Bearer ${token.accessToken}`,
},
});
function uploadFile(refreshedToken, name, parents, media = null, mediaType = 'text/plain', fileId = null, ifNotTooLate = cb => res => cb(res)) {
}, true)
.catch((err) => {
const reason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason;
if (reason === 'authError') {
// Mark the token as revoked and get a new one
store.dispatch('data/setGoogleToken', {
...token,
expiresOn: 0,
});
// Refresh token and retry
return this.refreshToken(token.scopes, token)
.then(refreshedToken => this.request(refreshedToken, options));
}
throw err;
});
},
uploadFileInternal(refreshedToken, name, parents, media = null, mediaType = 'text/plain', fileId = null, ifNotTooLate = cb => res => cb(res)) {
return Promise.resolve()
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late
// Refreshing a token can take a while if an oauth window pops up, make sure it's not too late
.then(ifNotTooLate(() => {
const options = {
method: 'POST',
@ -56,7 +74,7 @@ function uploadFile(refreshedToken, name, parents, media = null, mediaType = 'te
options.url = options.url.replace(
'https://www.googleapis.com/',
'https://www.googleapis.com/upload/');
return request(refreshedToken, {
return this.request(refreshedToken, {
...options,
params: {
uploadType: 'multipart',
@ -67,24 +85,34 @@ function uploadFile(refreshedToken, name, parents, media = null, mediaType = 'te
body: multipartRequestBody,
}).then(res => res.body);
}
return request(refreshedToken, {
return this.request(refreshedToken, {
...options,
body: metadata,
}).then(res => res.body);
}));
}
function downloadFile(refreshedToken, id) {
return request(refreshedToken, {
},
downloadFileInternal(refreshedToken, id) {
return this.request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`,
raw: true,
}).then(res => res.body);
}
export default {
},
getUser(userId) {
return networkSvc.request({
method: 'GET',
url: `https://www.googleapis.com/plus/v1/people/${userId}?key=${apiKey}`,
}, true)
.then((res) => {
store.commit('userInfo/addItem', {
id: res.body.id,
imageUrl: res.body.image.url,
});
return res.body;
});
},
startOauth2(scopes, sub = null, silent = false) {
return utils.startOauth2(
return networkSvc.startOauth2(
'https://accounts.google.com/o/oauth2/v2/auth', {
client_id: clientId,
response_type: 'token',
@ -94,13 +122,13 @@ export default {
prompt: silent ? 'none' : null,
}, silent)
// Call the token info endpoint
.then(data => utils.request({
.then(data => networkSvc.request({
method: 'POST',
url: 'https://www.googleapis.com/oauth2/v3/tokeninfo',
params: {
access_token: data.accessToken,
},
}).then((res) => {
}, true).then((res) => {
// Check the returned client ID consistency
if (res.body.aud !== clientId) {
throw new Error('Client ID inconsistent.');
@ -125,12 +153,10 @@ export default {
};
}))
// Call the user info endpoint
.then(token => request(token, {
method: 'GET',
url: 'https://www.googleapis.com/plus/v1/people/me',
}).then((res) => {
.then(token => this.getUser(token.sub)
.then((user) => {
// Add name to token
token.name = res.body.displayName;
token.name = user.displayName;
const existingToken = store.getters['data/googleTokens'][token.sub];
if (existingToken) {
// We probably retrieved a new token with restricted scopes.
@ -168,18 +194,22 @@ export default {
// Try to get a new token in background
return this.startOauth2(mergedScopes, sub, true)
// If it fails try to popup a window
.catch(() => utils.checkOnline() // Check that we are online, silent mode is a hack
.then(() => store.dispatch('modal/providerRedirection', {
.catch((err) => {
if (store.state.offline) {
throw err;
}
return store.dispatch('modal/providerRedirection', {
providerName: 'Google',
onResolve: () => this.startOauth2(mergedScopes, sub),
})));
});
});
});
},
loadClientScript() {
if (gapi) {
return Promise.resolve();
}
return utils.loadScript('https://apis.google.com/js/api.js')
return networkSvc.loadScript('https://apis.google.com/js/api.js')
.then(() => Promise.all(libraries.map(
library => new Promise((resolve, reject) => window.gapi.load(library, {
callback: resolve,
@ -210,7 +240,7 @@ export default {
};
return this.refreshToken(driveAppDataScopes, token)
.then((refreshedToken) => {
const getPage = (pageToken = '1') => request(refreshedToken, {
const getPage = (pageToken = '1') => this.request(refreshedToken, {
method: 'GET',
url: 'https://www.googleapis.com/drive/v3/changes',
params: {
@ -233,26 +263,26 @@ export default {
},
uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) {
return this.refreshToken(getDriveScopes(token), token)
.then(refreshedToken => uploadFile(
.then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate));
},
uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) {
return this.refreshToken(driveAppDataScopes, token)
.then(refreshedToken => uploadFile(
.then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate));
},
downloadFile(token, id) {
return this.refreshToken(getDriveScopes(token), token)
.then(refreshedToken => downloadFile(refreshedToken, id));
.then(refreshedToken => this.downloadFileInternal(refreshedToken, id));
},
downloadAppDataFile(token, id) {
return this.refreshToken(driveAppDataScopes, token)
.then(refreshedToken => downloadFile(refreshedToken, id));
.then(refreshedToken => this.downloadFileInternal(refreshedToken, id));
},
removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(driveAppDataScopes, token)
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate(refreshedToken => request(refreshedToken, {
.then(ifNotTooLate(refreshedToken => this.request(refreshedToken, {
method: 'DELETE',
url: `https://www.googleapis.com/drive/v3/files/${id}`,
})));
@ -266,7 +296,7 @@ export default {
if (blogId) {
return blogId;
}
return request(refreshedToken, {
return this.request(refreshedToken, {
url: 'https://www.googleapis.com/blogger/v3/blogs/byurl',
params: {
url: blogUrl,
@ -299,7 +329,7 @@ export default {
options.url += postId;
options.body.id = postId;
}
return request(refreshedToken, options)
return this.request(refreshedToken, options)
.then(res => res.body);
})
.then((post) => {
@ -319,7 +349,7 @@ export default {
options.params.publishDate = published.toISOString();
}
}
return request(refreshedToken, options)
return this.request(refreshedToken, options)
.then(res => res.body);
}));
},

View File

@ -1,10 +1,10 @@
import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store';
const clientId = '23361';
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (WordPress tokens expire after 2 weeks)
const request = (token, options) => utils.request({
const request = (token, options) => networkSvc.request({
...options,
headers: {
...options.headers || {},
@ -14,7 +14,7 @@ const request = (token, options) => utils.request({
export default {
startOauth2(sub = null, silent = false) {
return utils.startOauth2(
return networkSvc.startOauth2(
'https://public-api.wordpress.com/oauth2/authorize', {
client_id: clientId,
response_type: 'token',

View File

@ -1,7 +1,7 @@
import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store';
const request = (token, options) => utils.request({
const request = (token, options) => networkSvc.request({
...options,
headers: {
...options.headers || {},
@ -11,7 +11,7 @@ const request = (token, options) => utils.request({
export default {
startOauth2(subdomain, clientId, sub = null, silent = false) {
return utils.startOauth2(
return networkSvc.startOauth2(
`https://${subdomain}.zendesk.com/oauth/authorizations/new`, {
client_id: clientId,
response_type: 'token',

View File

@ -35,15 +35,6 @@ window.addEventListener('focus', setLastFocus);
// For addQueryParams()
const urlParser = window.document.createElement('a');
// For loadScript()
const scriptLoadingPromises = Object.create(null);
// For startOauth2
const oauth2AuthorizationTimeout = 120 * 1000; // 2 minutes
// For checkOnline
const checkOnlineTimeout = 15 * 1000; // 15 sec
export default {
workspaceId,
origin,
@ -142,216 +133,4 @@ export default {
urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
return urlParser.href;
},
loadScript(url) {
if (!scriptLoadingPromises[url]) {
scriptLoadingPromises[url] = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.onload = resolve;
script.onerror = () => {
scriptLoadingPromises[url] = null;
reject();
};
script.src = url;
document.head.appendChild(script);
});
}
return scriptLoadingPromises[url];
},
startOauth2(url, params = {}, silent = false) {
const oauth2Context = {};
// Build the authorize URL
const state = this.uid();
params.state = state;
params.redirect_uri = this.oauth2RedirectUri;
const authorizeUrl = this.addQueryParams(url, params);
if (silent) {
// Use an iframe as wnd for silent mode
oauth2Context.iframeElt = document.createElement('iframe');
oauth2Context.iframeElt.style.position = 'absolute';
oauth2Context.iframeElt.style.left = '-9999px';
oauth2Context.closeTimeout = setTimeout(
() => oauth2Context.clean('Unknown error.'),
checkOnlineTimeout);
oauth2Context.iframeElt.onerror = () => oauth2Context.clean('Unknown error.');
oauth2Context.iframeElt.src = authorizeUrl;
document.body.appendChild(oauth2Context.iframeElt);
oauth2Context.wnd = oauth2Context.iframeElt.contentWindow;
} else {
// Open a new tab otherwise
oauth2Context.wnd = window.open(authorizeUrl);
// If window opening has been blocked by the browser
if (!oauth2Context.wnd) {
return Promise.reject();
}
oauth2Context.closeTimeout = setTimeout(
() => oauth2Context.clean('Timeout.'),
oauth2AuthorizationTimeout);
}
return new Promise((resolve, reject) => {
oauth2Context.clean = (errorMsg) => {
clearInterval(oauth2Context.checkClosedInterval);
if (!silent && !oauth2Context.wnd.closed) {
oauth2Context.wnd.close();
}
if (oauth2Context.iframeElt) {
document.body.removeChild(oauth2Context.iframeElt);
}
clearTimeout(oauth2Context.closeTimeout);
window.removeEventListener('message', oauth2Context.msgHandler);
oauth2Context.clean = () => {
// Prevent from cleaning several times
};
if (errorMsg) {
reject(new Error(errorMsg));
}
};
oauth2Context.msgHandler = (event) => {
if (event.source === oauth2Context.wnd &&
event.origin === this.origin
) {
oauth2Context.clean();
const data = {};
`${event.data}`.slice(1).split('&').forEach((param) => {
const [key, value] = param.split('=').map(decodeURIComponent);
if (key === 'state') {
data.state = value;
} else if (key === 'access_token') {
data.accessToken = value;
} else if (key === 'code') {
data.code = value;
} else if (key === 'expires_in') {
data.expiresIn = value;
}
});
if (data.state === state) {
resolve(data);
return;
}
reject('Could not get required authorization.');
}
};
window.addEventListener('message', oauth2Context.msgHandler);
oauth2Context.checkClosedInterval = !silent && setInterval(() => {
if (oauth2Context.wnd.closed) {
oauth2Context.clean('Authorize window was closed.');
}
}, 250);
});
},
request(configParam) {
let retryAfter = 500; // 500 ms
const maxRetryAfter = 30 * 1000; // 30 sec
const config = Object.assign({}, configParam);
config.timeout = config.timeout || 30 * 1000; // 30 sec
config.headers = Object.assign({}, config.headers);
if (config.body && typeof config.body === 'object') {
config.body = JSON.stringify(config.body);
config.headers['Content-Type'] = 'application/json';
}
function parseHeaders(xhr) {
const pairs = xhr.getAllResponseHeaders().trim().split('\n');
return pairs.reduce((headers, header) => {
const split = header.trim().split(':');
const key = split.shift().trim().toLowerCase();
const value = split.join(':').trim();
headers[key] = value;
return headers;
}, {});
}
function isRetriable(err) {
switch (err.status) {
case 403:
{
const googleReason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason;
return googleReason === 'rateLimitExceeded' || googleReason === 'userRateLimitExceeded';
}
case 429:
return true;
default:
if (err.status >= 500 && err.status < 600) {
return true;
}
}
return false;
}
const attempt =
() => new Promise((resolve, reject) => {
const xhr = new window.XMLHttpRequest();
let timeoutId;
xhr.onload = () => {
clearTimeout(timeoutId);
const result = {
status: xhr.status,
headers: parseHeaders(xhr),
body: xhr.responseText,
};
if (!config.raw) {
try {
result.body = JSON.parse(result.body);
} catch (e) {
// ignore
}
}
if (result.status >= 200 && result.status < 300) {
resolve(result);
return;
}
reject(result);
};
xhr.onerror = () => {
clearTimeout(timeoutId);
reject(new Error('Network request failed.'));
};
timeoutId = setTimeout(() => {
xhr.abort();
reject(new Error('Network request timeout.'));
}, config.timeout);
const url = this.addQueryParams(config.url, config.params);
xhr.open(config.method || 'GET', url);
Object.keys(config.headers).forEach((key) => {
const value = config.headers[key];
if (value) {
xhr.setRequestHeader(key, `${value}`);
}
});
xhr.send(config.body || null);
})
.catch((err) => {
// Try again later in case of retriable error
if (isRetriable(err) && retryAfter < maxRetryAfter) {
return new Promise(
(resolve) => {
setTimeout(resolve, retryAfter);
// Exponential backoff
retryAfter *= 2;
})
.then(attempt);
}
throw err;
});
return attempt();
},
checkOnline() {
const checkStatus = (res) => {
if (!res.status || res.status < 200) {
throw new Error('Offline...');
}
};
return this.request({
url: 'https://www.googleapis.com/plus/v1/people/me',
timeout: checkOnlineTimeout,
})
.then(checkStatus, checkStatus);
},
};

View File

@ -15,15 +15,17 @@ import explorer from './modules/explorer';
import modal from './modules/modal';
import notification from './modules/notification';
import queue from './modules/queue';
import userInfo from './modules/userInfo';
Vue.use(Vuex);
const debug = process.env.NODE_ENV !== 'production';
const debug = NODE_ENV !== 'production';
const store = new Vuex.Store({
state: {
ready: false,
offline: false,
lastOfflineCheck: 0,
},
getters: {
allItemMap: (state) => {
@ -39,6 +41,21 @@ const store = new Vuex.Store({
setOffline: (state, value) => {
state.offline = value;
},
updateLastOfflineCheck: (state) => {
state.lastOfflineCheck = Date.now();
},
},
actions: {
setOffline: ({ state, commit }, value) => {
if (state.offline !== value) {
commit('setOffline', value);
if (state.offline) {
return Promise.reject('You are offline.');
}
store.dispatch('notification/info', 'You are back online!');
}
return Promise.resolve();
},
},
modules: {
contentState,
@ -54,37 +71,10 @@ const store = new Vuex.Store({
modal,
notification,
queue,
userInfo,
},
strict: debug,
plugins: debug ? [createLogger()] : [],
});
let isConnectionDown = false;
let lastConnectionCheck = 0;
function checkOffline() {
const isBrowserOffline = window.navigator.onLine === false;
if (!isBrowserOffline && lastConnectionCheck + 30000 < Date.now() && utils.isUserActive()) {
lastConnectionCheck = Date.now();
utils.checkOnline()
.then(() => {
isConnectionDown = false;
}, () => {
isConnectionDown = true;
});
}
const isOffline = isBrowserOffline || isConnectionDown;
if (isOffline !== store.state.offline) {
store.commit('setOffline', isOffline);
if (isOffline) {
store.dispatch('notification/info', 'You are offline.');
} else {
store.dispatch('notification/info', 'You are back online!');
}
}
}
utils.setInterval(checkOffline, 1000);
window.addEventListener('online', checkOffline);
window.addEventListener('offline', checkOffline);
export default store;

View File

@ -16,7 +16,7 @@ const constants = {
explorerWidth: 250,
sideBarWidth: 280,
navigationBarHeight: 44,
buttonBarWidth: 30,
buttonBarWidth: 26,
statusBarHeight: 20,
};
@ -99,15 +99,13 @@ function computeStyles(state, localSettings, getters, styles = {
if (styles.showEditor) {
const syncLocations = getters['syncLocation/current'];
const publishLocations = getters['publishLocation/current'];
const isSyncPossible = getters['data/loginToken'] || syncLocations.length;
styles.titleMaxWidth = styles.innerWidth -
navigationBarEditButtonsWidth -
navigationBarLeftButtonWidth -
navigationBarRightButtonWidth -
navigationBarSpinnerWidth -
(navigationBarLocationWidth * (syncLocations.length + publishLocations.length)) -
(isSyncPossible ? navigationBarSyncPublishButtonsWidth : 0) -
(publishLocations.length ? navigationBarSyncPublishButtonsWidth : 0) -
(navigationBarSyncPublishButtonsWidth * 2) -
navigationBarTitleMargin;
if (styles.titleMaxWidth + navigationBarEditButtonsWidth < minTitleMaxWidth) {
styles.hideLocations = true;

View File

@ -0,0 +1,13 @@
import Vue from 'vue';
export default {
namespaced: true,
state: {
itemMap: {},
},
mutations: {
addItem: (state, item) => {
Vue.set(state.itemMap, item.id, item);
},
},
};