CouchDB workspace (part 1)

This commit is contained in:
benweet 2018-01-24 07:31:54 +00:00
parent ec0d5aac3e
commit aac305e410
21 changed files with 379 additions and 36 deletions

View File

@ -0,0 +1,4 @@
<svg width="100%" height="100%" viewBox="0 0 500 500" version="1.1"
xmlns="http://www.w3.org/2000/svg">
<path d="M405.365,303.996c0,20.375 -11.207,30.563 -31.582,31.582l-248.584,0c-20.376,0 -31.583,-10.188 -31.583,-31.582c0,-20.376 11.207,-30.564 31.583,-31.583l249.602,0c20.376,1.019 30.564,11.207 30.564,31.583Zm-30.564,46.864l-249.602,0c-20.376,0 -31.583,10.188 -31.583,31.582c0,20.376 11.207,30.564 31.583,31.583l249.602,0c20.376,0 31.583,-10.188 31.583,-31.583c-1.019,-21.394 -11.207,-31.582 -31.583,-31.582Zm77.428,-172.175c-20.376,0 -31.582,10.188 -31.582,30.563l0,172.175c0,20.376 11.206,30.564 31.582,31.583c30.564,-1.019 46.864,-31.583 46.864,-93.729l0,-77.427c0,-41.771 -16.3,-62.146 -46.864,-63.165Zm-404.458,0c-30.564,1.019 -46.864,21.394 -46.864,63.165l0,77.427c0,62.146 16.3,92.71 46.864,93.729c20.376,0 31.582,-10.188 31.582,-31.583l0,-171.156c-1.019,-20.375 -11.206,-30.563 -31.582,-31.582Zm404.458,-15.282c0,-51.958 -27.507,-76.409 -77.428,-77.428l-249.602,0c-50.94,1.019 -77.428,26.489 -77.428,77.428c30.563,0 46.864,16.301 46.864,46.864c0,30.564 16.301,46.864 46.864,46.864l218.021,0c30.563,0 46.864,-16.3 46.864,-46.864c-1.019,-31.582 16.3,-45.845 45.845,-46.864Z" style="fill:#e42528;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -33,6 +33,8 @@
<blogger-page-publish-modal v-else-if="config.type === 'bloggerPagePublish'"></blogger-page-publish-modal>
<zendesk-account-modal v-else-if="config.type === 'zendeskAccount'"></zendesk-account-modal>
<zendesk-publish-modal v-else-if="config.type === 'zendeskPublish'"></zendesk-publish-modal>
<couchdb-workspace-modal v-else-if="config.type === 'couchdbWorkspace'"></couchdb-workspace-modal>
<couchdb-credentials-modal v-else-if="config.type === 'couchdbCredentials'"></couchdb-credentials-modal>
<modal-inner v-else aria-label="Dialog">
<div class="modal__content" v-html="config.content"></div>
<div class="modal__button-bar">
@ -81,6 +83,8 @@ import BloggerPublishModal from './modals/providers/BloggerPublishModal';
import BloggerPagePublishModal from './modals/providers/BloggerPagePublishModal';
import ZendeskAccountModal from './modals/providers/ZendeskAccountModal';
import ZendeskPublishModal from './modals/providers/ZendeskPublishModal';
import CouchdbWorkspaceModal from './modals/providers/CouchdbWorkspaceModal';
import CouchdbCredentialsModal from './modals/providers/CouchdbCredentialsModal';
const getTabbables = container => container.querySelectorAll('a[href], button, .textfield')
// Filter enabled and visible element
@ -122,6 +126,8 @@ export default {
BloggerPagePublishModal,
ZendeskAccountModal,
ZendeskPublishModal,
CouchdbWorkspaceModal,
CouchdbCredentialsModal,
},
computed: mapGetters('modal', [
'config',

View File

@ -3,7 +3,7 @@
<div class="tour-step" :class="'tour-step--' + step" :style="stepStyle">
<div class="tour-step__inner" v-if="step === 'welcome'">
<h2>Welcome to StackEdit!</h2>
<p>Greater, lighter, faster... StackEdit 5 is here!</p>
<p>Greater, lighter, faster... <b>StackEdit 5</b> is here!</p>
<p>Please click <b>Next</b> to take a quick tour.</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">Skip</button>
@ -139,7 +139,7 @@ export default {
}
$tour-step-background: mix(#fafafa, $selection-highlighting-color, 80%);
$tour-step-width: 220px;
$tour-step-width: 240px;
.tour-step__inner {
position: absolute;

View File

@ -1,31 +1,29 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<div class="menu-info-entries" v-if="!loginToken">
<div class="menu-entry menu-entry--info flex flex--row flex--align-center">
<div class="menu-info-entries">
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-if="loginToken">
<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>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-if="syncToken">
<div class="menu-entry__icon menu-entry__icon--image">
<icon-provider :provider-id="currentWorkspace.providerId"></icon-provider>
</div>
<span><b>{{currentWorkspace.name}}</b> synced.</span>
</div>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-else>
<div class="menu-entry__icon menu-entry__icon--disabled">
<icon-sync-off></icon-sync-off>
</div>
<span><b>{{currentWorkspace.name}}</b> not synced.</span>
</div>
</div>
<div class="menu-info-entries" v-else>
<div class="menu-entry menu-entry--info 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>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center">
<div class="menu-entry__icon menu-entry__icon--image">
<icon-provider :provider-id="currentWorkspace.providerId"></icon-provider>
</div>
<span><b>{{currentWorkspace.name}}</b> synced.</span>
</div>
</div>
<menu-entry v-if="!loginToken" @click.native="signin">
<icon-login slot="icon"></icon-login>
<div>Sign in with Google</div>
<span>Back up and sync your main workspace.</span>
<span>Sync your main workspace and unlock functionalities.</span>
</menu-entry>
<menu-entry @click.native="setPanel('workspaces')">
<icon-database slot="icon"></icon-database>
@ -103,6 +101,7 @@ export default {
computed: {
...mapGetters('workspace', [
'currentWorkspace',
'syncToken',
'loginToken',
]),
},

View File

@ -11,6 +11,11 @@
<icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider>
<span>Add Google Drive workspace</span>
</menu-entry>
<menu-entry @click.native="addCouchdbWorkspace">
<icon-provider slot="icon" provider-id="couchdbWorkspace"></icon-provider>
<span>Add CouchDB workspace</span>
</menu-entry>
<hr>
<menu-entry @click.native="manageWorkspaces">
<icon-database slot="icon"></icon-database>
<span>Manage workspaces</span>
@ -44,6 +49,12 @@ export default {
}))
.catch(() => {}); // Cancel
},
addCouchdbWorkspace() {
return this.$store.dispatch('modal/open', {
type: 'couchdbWorkspace',
})
.catch(() => {}); // Cancel
},
manageWorkspaces() {
return this.$store.dispatch('modal/open', 'workspaceManagement');
},
@ -62,9 +73,5 @@ export default {
.workspace__name {
font-weight: bold;
line-height: 1.2;
.menu-entry div & {
text-decoration: none;
}
}
</style>

View File

@ -27,7 +27,6 @@
<script>
import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner';
import htmlSanitizer from '../../libs/htmlSanitizer';
import markdownConversionSvc from '../../services/markdownConversionSvc';
import faq from '../../data/faq.md';
@ -43,7 +42,7 @@ export default {
'config',
]),
faq() {
return htmlSanitizer.sanitizeHtml(markdownConversionSvc.defaultConverter.render(faq));
return markdownConversionSvc.defaultConverter.render(faq);
},
},
};

View File

@ -0,0 +1,54 @@
<template>
<modal-inner aria-label="Insert image">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="couchdb"></icon-provider>
</div>
<p>Please provide your credentials to login to <b>CouchDB</b>.</p>
<form-entry label="Name" error="name">
<input slot="field" class="textfield" type="text" v-model.trim="name" @keyup.enter="resolve()">
</form-entry>
<form-entry label="Password" error="password">
<input slot="field" class="textfield" type="password" v-model.trim="password" @keyup.enter="resolve()">
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({
name: '',
password: '',
}),
created() {
this.name = this.config.token.name;
this.password = this.config.token.password;
},
methods: {
resolve() {
if (!this.name) {
this.setError('name');
}
if (!this.password) {
this.setError('password');
}
if (this.name && this.password) {
const token = {
...this.config.token,
name: this.name,
password: this.password,
};
this.$store.dispatch('data/setCouchdbToken', token);
this.config.resolve();
}
},
},
});
</script>

View File

@ -0,0 +1,63 @@
<template>
<modal-inner aria-label="Add CouchDB workspace">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="couchdb"></icon-provider>
</div>
<p>This will create a workspace synchronized with a <b>CouchDB</b> database.</p>
<form-entry label="Database URL" error="dbUrl">
<input slot="field" class="textfield" type="text" v-model.trim="dbUrl" @keyup.enter="resolve()">
<div class="form-entry__info">
<b>Example:</b> https://instance.smileupps.com/stackedit-workspace
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" v-if="!showInfo" @click="showInfo = true">More info</a>
</div>
</form-entry>
<div class="couchdb-workspace__info" v-if="showInfo" v-html="info"></div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils';
import couchdbSetup from '../../../data/couchdbSetup.md';
import markdownConversionSvc from '../../../services/markdownConversionSvc';
export default modalTemplate({
data: () => ({
dbUrl: '',
showInfo: false,
}),
computed: {
info() {
return markdownConversionSvc.defaultConverter.render(couchdbSetup);
},
},
methods: {
resolve() {
if (!this.dbUrl) {
this.setError('dbUrl');
} else {
const url = utils.addQueryParams('app', {
providerId: 'couchdbWorkspace',
dbUrl: this.dbUrl,
}, true);
this.config.resolve();
window.open(url);
}
},
},
});
</script>
<style lang="scss">
.couchdb-workspace__info {
font-size: 0.8em;
}
</style>

27
src/data/couchdbSetup.md Normal file
View File

@ -0,0 +1,27 @@
### Pre-requisites
- CouchDB 0.11 or later,
- In order to work with https://stackedit.io, your database needs to be accessible over HTTPS.
> **Tip:** [Smileupps](https://www.smileupps.com/) provide free CouchDB hosting.
### Enable CORS
Add the following key/value pairs to your CouchDB configuration:
```
[httpd]
enable_cors = true
[cors]
origins = https://stackedit.io
```
### Create the database
```bash
curl -X PUT https://instance.smileupps.com/stackedit-workspace
```
> You may want to restrict access to the database by [specifying members](http://docs.couchdb.org/en/latest/api/database/security.html).

View File

@ -7,6 +7,7 @@ We recommend syncing your workspace to make sure files won't be lost in case you
If you sign in with Google, your main workspace will be stored in Google Drive (in your [app data folder](https://developers.google.com/drive/v3/web/appdata)).
If you open a Google Drive workspace, the files in the workspace will be stored inside a Google Drive folder which you can share with other users.
If you open a CouchDB workspace, the files in the workspace will be stored in the CouchDB database which can be hosted on premises for privacy concerns.
**Can StackEdit access my data without telling me?**

View File

@ -22,6 +22,8 @@ export default {
return 'github';
case 'bloggerPage':
return 'blogger';
case 'couchdbWorkspace':
return 'couchdb';
default:
return this.providerId;
}
@ -70,4 +72,8 @@ export default {
.icon-provider--zendesk {
background-image: url(../assets/iconZendesk.svg);
}
.icon-provider--couchdb {
background-image: url(../assets/iconCouchdb.svg);
}
</style>

View File

@ -459,7 +459,7 @@ const localDbSvc = {
*/
removeWorkspace(id) {
const workspaces = {
...this.workspaces,
...store.getters['data/workspaces'],
};
delete workspaces[id];
store.dispatch('data/setWorkspaces', workspaces);

View File

@ -221,6 +221,7 @@ export default {
store.commit('updateLastOfflineCheck');
}
const xhr = new window.XMLHttpRequest();
xhr.withCredentials = config.withCredentials || false;
let timeoutId;
xhr.onload = () => {

View File

@ -0,0 +1,87 @@
import store from '../../store';
import couchdbHelper from './helpers/couchdbHelper';
import providerRegistry from './providerRegistry';
import utils from '../utils';
export default providerRegistry.register({
id: 'couchdbWorkspace',
getToken() {
return store.getters['workspace/syncToken'];
},
initWorkspace() {
const dbUrl = (utils.queryParams.dbUrl || '').replace(/\/?$/, ''); // Remove trailing /
const workspaceIdParams = {
providerId: this.id,
dbUrl,
};
const workspaceId = utils.makeWorkspaceId(workspaceIdParams);
const getToken = () => store.getters['data/couchdbTokens'][workspaceId];
const getWorkspace = () => store.getters['data/sanitizedWorkspaces'][workspaceId];
if (!getToken()) {
// Create token
store.dispatch('data/setCouchdbToken', {
sub: workspaceId,
dbUrl,
});
}
return Promise.resolve()
.then(() => getWorkspace() || couchdbHelper.getDb(getToken())
.then((db) => {
store.dispatch('data/patchWorkspaces', {
[workspaceId]: {
id: workspaceId,
name: db.db_name,
providerId: this.id,
dbUrl,
},
});
return getWorkspace();
}, () => {
throw new Error(`${dbUrl} is not accessible. Make sure you have the right permissions.`);
}))
.then((workspace) => {
// Fix the URL hash
utils.setQueryParams(workspaceIdParams);
if (workspace.url !== location.href) {
store.dispatch('data/patchWorkspaces', {
[workspace.id]: {
...workspace,
url: location.href,
},
});
}
return getWorkspace();
});
},
getChanges() {
const workspace = store.getters['workspace/currentWorkspace'];
const syncToken = store.getters['workspace/syncToken'];
const lastSeq = store.getters['data/localSettings'].syncLastSeq;
return couchdbHelper.getChanges(syncToken, lastSeq, true)
.then((result) => {
const changes = result.changes.filter((change) => {
if (change.file) {
// Parse item from file name
try {
change.item = JSON.parse(change.file.name);
} catch (e) {
return false;
}
// Build sync data
change.syncData = {
id: change.fileId,
itemId: change.item.id,
type: change.item.type,
hash: change.item.hash,
};
}
change.syncDataId = change.fileId;
return true;
});
changes.startPageToken = result.startPageToken;
return changes;
});
},
});

View File

@ -100,7 +100,7 @@ export default providerRegistry.register({
});
// Return the workspace
return getWorkspace(folder.id);
return store.getters['data/sanitizedWorkspaces'][workspaceId];
});
return Promise.resolve()
@ -148,7 +148,15 @@ export default providerRegistry.register({
.then((workspace) => {
// Fix the URL hash
utils.setQueryParams(makeWorkspaceIdParams(workspace.folderId));
return workspace;
if (workspace.url !== location.href) {
store.dispatch('data/patchWorkspaces', {
[workspace.id]: {
...workspace,
url: location.href,
},
});
}
return store.getters['data/sanitizedWorkspaces'][workspace.id];
}));
},
performAction() {

View File

@ -0,0 +1,50 @@
import networkSvc from '../../networkSvc';
import utils from '../../utils';
import store from '../../../store';
const request = (token, options = {}) => {
const baseUrl = `${token.dbUrl}/`;
const getLastToken = () => store.getters['data/couchdbTokens'][token.sub];
const ifUnauthorized = cb => (err) => {
if (err.status !== 401) {
throw err;
}
return cb(err);
};
const onUnauthorized = () => networkSvc.request({
method: 'POST',
url: utils.resolveUrl(baseUrl, '../_session'),
withCredentials: true,
body: {
name: getLastToken().name,
password: getLastToken().password,
},
})
.catch(ifUnauthorized(() => store.dispatch('modal/open', {
type: 'couchdbCredentials',
token: getLastToken(),
})
.then(onUnauthorized)));
const config = {
...options,
url: utils.resolveUrl(baseUrl, options.path || '.'),
withCredentials: true,
};
return networkSvc.request(config)
.catch(ifUnauthorized(() => onUnauthorized()
.then(() => networkSvc.request(config))));
};
export default {
getDb(token) {
return request(token)
.then(res => res.body);
},
getChanges() {
},
};

View File

@ -6,6 +6,7 @@ import networkSvc from './networkSvc';
import providerRegistry from './providers/providerRegistry';
import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider';
import './providers/googleDriveWorkspaceProvider';
import './providers/couchdbWorkspaceProvider';
const inactivityThreshold = 3 * 1000; // 3 sec
const restartSyncAfter = 30 * 1000; // 30 sec

View File

@ -171,8 +171,19 @@ export default {
}
return urlParser.href;
},
resolveUrl(url) {
return this.addQueryParams(url);
resolveUrl(baseUrl, path) {
const oldBaseElt = document.getElementsByTagName('base')[0];
const oldHref = oldBaseElt && oldBaseElt.href;
const newBaseElt = oldBaseElt || document.head.appendChild(document.createElement('base'));
newBaseElt.href = baseUrl;
urlParser.href = path;
const result = urlParser.href;
if (oldBaseElt) {
oldBaseElt.href = oldHref;
} else {
document.head.removeChild(newBaseElt);
}
return result;
},
createHiddenIframe(url) {
const iframeElt = document.createElement('iframe');

View File

@ -207,6 +207,7 @@ export default {
dataSyncData: getter('dataSyncData'),
tokens: getter('tokens'),
googleTokens: (state, getters) => getters.tokens.google || {},
couchdbTokens: (state, getters) => getters.tokens.couchdb || {},
dropboxTokens: (state, getters) => getters.tokens.dropbox || {},
githubTokens: (state, getters) => getters.tokens.github || {},
wordpressTokens: (state, getters) => getters.tokens.wordpress || {},
@ -281,6 +282,7 @@ export default {
patchDataSyncData: patcher('dataSyncData'),
patchTokens: patcher('tokens'),
setGoogleToken: tokenSetter('google'),
setCouchdbToken: tokenSetter('couchdb'),
setDropboxToken: tokenSetter('dropbox'),
setGithubToken: tokenSetter('github'),
setWordpressToken: tokenSetter('wordpress'),

View File

@ -121,7 +121,7 @@ export default {
onResolve,
}),
sponsorOnly: ({ dispatch }) => dispatch('open', {
content: '<p>This feature is restricted to <b>sponsor users</b> as it relies on server resources.</p>',
content: '<p>This feature is restricted to sponsors as it relies on server resources.</p>',
resolveText: 'Ok, I understand',
}),
paymentSuccess: ({ dispatch }) => dispatch('open', {

View File

@ -31,13 +31,30 @@ export default {
},
syncToken: (state, getters, rootState, rootGetters) => {
const workspace = getters.currentWorkspace;
if (workspace.providerId === 'googleDriveWorkspace') {
const googleTokens = rootGetters['data/googleTokens'];
return googleTokens[workspace.sub];
switch (workspace.providerId) {
case 'googleDriveWorkspace': {
const googleTokens = rootGetters['data/googleTokens'];
return googleTokens[workspace.sub];
}
case 'couchdbWorkspace': {
const couchdbTokens = rootGetters['data/couchdbTokens'];
return couchdbTokens[workspace.id];
}
default:
return getters.mainWorkspaceToken;
}
},
loginToken: (state, getters, rootState, rootGetters) => {
const workspace = getters.currentWorkspace;
switch (workspace.providerId) {
case 'googleDriveWorkspace': {
const googleTokens = rootGetters['data/googleTokens'];
return googleTokens[workspace.sub];
}
default:
return getters.mainWorkspaceToken;
}
return getters.mainWorkspaceToken;
},
loginToken: (state, getters) => getters.syncToken,
sponsorToken: (state, getters) => getters.mainWorkspaceToken,
},
actions: {