mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-11 18:24:43 +02:00
Desktop, Clipper: Web Clipper now must request authorisation before accessing the application data
This commit is contained in:
parent
638b3236cf
commit
67d9977489
@ -809,6 +809,9 @@ packages/lib/BaseModel.js.map
|
||||
packages/lib/BaseSyncTarget.d.ts
|
||||
packages/lib/BaseSyncTarget.js
|
||||
packages/lib/BaseSyncTarget.js.map
|
||||
packages/lib/ClipperServer.d.ts
|
||||
packages/lib/ClipperServer.js
|
||||
packages/lib/ClipperServer.js.map
|
||||
packages/lib/HtmlToMd.d.ts
|
||||
packages/lib/HtmlToMd.js
|
||||
packages/lib/HtmlToMd.js.map
|
||||
@ -1331,6 +1334,9 @@ packages/lib/services/rest/ApiResponse.js.map
|
||||
packages/lib/services/rest/actionApi.desktop.d.ts
|
||||
packages/lib/services/rest/actionApi.desktop.js
|
||||
packages/lib/services/rest/actionApi.desktop.js.map
|
||||
packages/lib/services/rest/routes/auth.d.ts
|
||||
packages/lib/services/rest/routes/auth.js
|
||||
packages/lib/services/rest/routes/auth.js.map
|
||||
packages/lib/services/rest/routes/folders.d.ts
|
||||
packages/lib/services/rest/routes/folders.js
|
||||
packages/lib/services/rest/routes/folders.js.map
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -795,6 +795,9 @@ packages/lib/BaseModel.js.map
|
||||
packages/lib/BaseSyncTarget.d.ts
|
||||
packages/lib/BaseSyncTarget.js
|
||||
packages/lib/BaseSyncTarget.js.map
|
||||
packages/lib/ClipperServer.d.ts
|
||||
packages/lib/ClipperServer.js
|
||||
packages/lib/ClipperServer.js.map
|
||||
packages/lib/HtmlToMd.d.ts
|
||||
packages/lib/HtmlToMd.js
|
||||
packages/lib/HtmlToMd.js.map
|
||||
@ -1317,6 +1320,9 @@ packages/lib/services/rest/ApiResponse.js.map
|
||||
packages/lib/services/rest/actionApi.desktop.d.ts
|
||||
packages/lib/services/rest/actionApi.desktop.js
|
||||
packages/lib/services/rest/actionApi.desktop.js.map
|
||||
packages/lib/services/rest/routes/auth.d.ts
|
||||
packages/lib/services/rest/routes/auth.js
|
||||
packages/lib/services/rest/routes/auth.js.map
|
||||
packages/lib/services/rest/routes/folders.d.ts
|
||||
packages/lib/services/rest/routes/folders.js
|
||||
packages/lib/services/rest/routes/folders.js.map
|
||||
|
@ -82,6 +82,8 @@ class Command extends BaseCommand {
|
||||
lines.push('');
|
||||
lines.push('In the documentation below, the token will not be specified every time however you will need to include it.');
|
||||
lines.push('');
|
||||
lines.push('If needed you may also [request the token programmatically](https://github.com/laurent22/joplin/blob/dev/readme/spec/clipper_auth.md)');
|
||||
lines.push('');
|
||||
|
||||
lines.push('# Using the API');
|
||||
lines.push('');
|
||||
@ -114,6 +116,7 @@ class Command extends BaseCommand {
|
||||
lines.push('\tcurl http://localhost:41184/tags?fields=id');
|
||||
lines.push('');
|
||||
lines.push('By default API results will contain the following fields: **id**, **parent_id**, **title**');
|
||||
lines.push('');
|
||||
|
||||
lines.push('# Pagination');
|
||||
lines.push('');
|
||||
|
@ -17,7 +17,7 @@ class Command extends BaseCommand {
|
||||
async action(args) {
|
||||
const command = args.command;
|
||||
|
||||
const ClipperServer = require('@joplin/lib/ClipperServer');
|
||||
const ClipperServer = require('@joplin/lib/ClipperServer').default;
|
||||
ClipperServer.instance().initialize();
|
||||
const stdoutFn = (...s) => this.stdout(s.join(' '));
|
||||
const clipperLogger = new Logger();
|
||||
|
@ -106,7 +106,7 @@ browser_.runtime.onMessage.addListener(async (command) => {
|
||||
newArea.height *= ratio;
|
||||
content.crop_rect = newArea;
|
||||
|
||||
fetch(`${command.api_base_url}/notes`, {
|
||||
fetch(`${command.api_base_url}/notes?token=${encodeURIComponent(command.token)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
|
@ -521,6 +521,7 @@
|
||||
name: 'screenshotArea',
|
||||
content: content,
|
||||
api_base_url: command.api_base_url,
|
||||
token: command.token,
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
line-height: 2em;
|
||||
}
|
||||
|
||||
.App {
|
||||
@ -13,11 +14,19 @@ body {
|
||||
flex-direction: column;
|
||||
background-color: #162b3d;
|
||||
font-size: 16px;
|
||||
color: #5A95C7;
|
||||
color: #c1e2ff;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.App.Startup {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.App.Startup h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.App h2 {
|
||||
font-size: 1em;
|
||||
margin-top: .5em;
|
||||
@ -26,7 +35,7 @@ body {
|
||||
}
|
||||
|
||||
.App a {
|
||||
color: #5A95C7;
|
||||
color: #c1e2ff;
|
||||
}
|
||||
|
||||
.App .Disabled {
|
||||
@ -139,7 +148,7 @@ body {
|
||||
}
|
||||
|
||||
.App .StatusBar {
|
||||
color: #5A95C7;
|
||||
color: #c1e2ff;
|
||||
font-size: .7em;
|
||||
display: flex;
|
||||
flex: 0;
|
||||
|
@ -50,17 +50,6 @@ class PreviewComponent extends React.PureComponent {
|
||||
<a className={'Confirm Button'} href="#" onClick={this.props.onConfirmClick}>Confirm</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
// return (
|
||||
// <div className="Preview">
|
||||
// <a className={"Confirm Button"} onClick={this.props.onConfirmClick}>Confirm</a>
|
||||
// <h2>Preview:</h2>
|
||||
// <input className={"Title"} value={this.props.title} onChange={this.props.onTitleChange}/>
|
||||
// <div className={"BodyWrapper"}>
|
||||
// <div className={"Body"} ref={this.bodyRef} dangerouslySetInnerHTML={{__html: this.props.body_html}}></div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
}
|
||||
|
||||
}
|
||||
@ -131,6 +120,7 @@ class AppComponent extends Component {
|
||||
api_base_url: baseUrl,
|
||||
parent_id: this.props.selectedFolderId,
|
||||
tags: this.state.selectedTags.join(','),
|
||||
token: bridge().token(),
|
||||
});
|
||||
|
||||
window.close();
|
||||
@ -194,6 +184,8 @@ class AppComponent extends Component {
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
bridge().onReactAppStarts();
|
||||
|
||||
try {
|
||||
await this.loadContentScripts();
|
||||
} catch (error) {
|
||||
@ -242,7 +234,46 @@ class AppComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
renderStartupScreen() {
|
||||
const messages = {
|
||||
serverFoundState: {
|
||||
'searching': 'Connecting to the Joplin application...',
|
||||
'not_found': 'Error: Could not connect to the Joplin application. Please ensure that it is started and that the clipper service is enabled in the configuration.',
|
||||
},
|
||||
authState: {
|
||||
'starting': 'Starting...',
|
||||
'waiting': 'The Joplin Web Clipper requires your authorisation in order to access your data. To do, please open the Joplin desktop application and grant permission. Note: Joplin 2.1+ is needed to use this version of the Web Clipper.',
|
||||
'rejected': 'Permission to access your data was not granted. To try again please close this popup and open it again.',
|
||||
},
|
||||
};
|
||||
|
||||
const foundState = this.props.clipperServer.foundState;
|
||||
|
||||
let msg = '';
|
||||
let title = '';
|
||||
|
||||
if (messages.serverFoundState[foundState]) {
|
||||
msg = messages.serverFoundState[foundState];
|
||||
} else {
|
||||
msg = messages.authState[this.props.authStatus];
|
||||
title = <h1>{'Permission needed'}</h1>;
|
||||
}
|
||||
|
||||
if (!msg) throw new Error(`Invalidate state: ${foundState} / ${this.props.authStatus}`);
|
||||
|
||||
return (
|
||||
<div className="App Startup">
|
||||
{title}
|
||||
{msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.authStatus !== 'accepted') {
|
||||
return this.renderStartupScreen();
|
||||
}
|
||||
|
||||
if (!this.state.contentScriptLoaded) {
|
||||
let msg = 'Loading...';
|
||||
if (this.state.contentScriptError) msg = `The Joplin extension is not available on this tab due to: ${this.state.contentScriptError}`;
|
||||
@ -417,6 +448,7 @@ const mapStateToProps = (state) => {
|
||||
tags: state.tags,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
isProbablyReaderable: state.isProbablyReaderable,
|
||||
authStatus: state.authStatus,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,9 +1,18 @@
|
||||
const { randomClipperPort } = require('./randomClipperPort');
|
||||
|
||||
function msleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
|
||||
class Bridge {
|
||||
|
||||
constructor() {
|
||||
this.nounce_ = Date.now();
|
||||
this.token_ = null;
|
||||
}
|
||||
|
||||
async init(browser, browserSupportsPromises, store) {
|
||||
@ -77,7 +86,82 @@ class Bridge {
|
||||
env: this.env(),
|
||||
});
|
||||
|
||||
this.findClipperServerPort();
|
||||
await this.findClipperServerPort();
|
||||
|
||||
if (this.clipperServerPortStatus_ !== 'found') {
|
||||
console.info('Skipping initialisation because server port was not found');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.restoreState();
|
||||
}
|
||||
|
||||
token() {
|
||||
return this.token_;
|
||||
}
|
||||
|
||||
async onReactAppStarts() {
|
||||
await this.checkAuth();
|
||||
if (!this.token_) return; // Didn't get a token
|
||||
|
||||
const folders = await this.folderTree();
|
||||
this.dispatch({ type: 'FOLDERS_SET', folders: folders.items ? folders.items : folders });
|
||||
|
||||
let tags = [];
|
||||
for (let page = 1; page < 10000; page++) {
|
||||
const result = await this.clipperApiExec('GET', 'tags', { page: page, order_by: 'title', order_dir: 'ASC' });
|
||||
const resultTags = result.items ? result.items : result;
|
||||
const hasMore = ('has_more' in result) && result.has_more;
|
||||
tags = tags.concat(resultTags);
|
||||
if (!hasMore) break;
|
||||
}
|
||||
|
||||
this.dispatch({ type: 'TAGS_SET', tags: tags });
|
||||
}
|
||||
|
||||
async checkAuth() {
|
||||
this.dispatch({ type: 'AUTH_STATE_SET', value: 'starting' });
|
||||
|
||||
const existingToken = await this.storageGet(['token']);
|
||||
this.token_ = existingToken.token;
|
||||
|
||||
const authCheckResponse = await this.clipperApiExec('GET', 'auth/check', { token: this.token_ });
|
||||
|
||||
if (authCheckResponse.valid) {
|
||||
console.info('checkAuth: we already have a valid token - exiting');
|
||||
this.dispatch({ type: 'AUTH_STATE_SET', value: 'accepted' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.token_ = null;
|
||||
await this.storageSet({ token: this.token_ });
|
||||
|
||||
this.dispatch({ type: 'AUTH_STATE_SET', value: 'waiting' });
|
||||
|
||||
const response = await this.clipperApiExec('POST', 'auth');
|
||||
const authToken = response.auth_token;
|
||||
|
||||
console.info('checkAuth: we do not have a token - requesting one using auth_token: ', authToken);
|
||||
|
||||
while (true) {
|
||||
const response = await this.clipperApiExec('GET', 'auth/check', { auth_token: authToken });
|
||||
|
||||
if (response.status === 'rejected') {
|
||||
console.info('checkAuth: Auth request was not accepted', response);
|
||||
this.dispatch({ type: 'AUTH_STATE_SET', value: 'rejected' });
|
||||
break;
|
||||
} else if (response.status === 'accepted') {
|
||||
console.info('checkAuth: Auth request was accepted', response);
|
||||
this.dispatch({ type: 'AUTH_STATE_SET', value: 'accepted' });
|
||||
this.token_ = response.token;
|
||||
await this.storageSet({ token: this.token_ });
|
||||
break;
|
||||
} else if (response.status === 'waiting') {
|
||||
await msleep(1000);
|
||||
} else {
|
||||
throw new Error(`Unknown auth/check status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async backgroundPage(browser) {
|
||||
@ -146,22 +230,6 @@ class Bridge {
|
||||
this.clipperServerPortStatus_ = 'found';
|
||||
this.clipperServerPort_ = state.port;
|
||||
this.dispatch({ type: 'CLIPPER_SERVER_SET', foundState: 'found', port: state.port });
|
||||
|
||||
const folders = await this.folderTree();
|
||||
this.dispatch({ type: 'FOLDERS_SET', folders: folders.items ? folders.items : folders });
|
||||
|
||||
let tags = [];
|
||||
for (let page = 1; page < 10000; page++) {
|
||||
const result = await this.clipperApiExec('GET', 'tags', { page: page, order_by: 'title', order_dir: 'ASC' });
|
||||
const resultTags = result.items ? result.items : result;
|
||||
const hasMore = ('has_more' in result) && result.has_more;
|
||||
tags = tags.concat(resultTags);
|
||||
if (!hasMore) break;
|
||||
}
|
||||
|
||||
this.dispatch({ type: 'TAGS_SET', tags: tags });
|
||||
|
||||
bridge().restoreState();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
@ -311,6 +379,8 @@ class Bridge {
|
||||
|
||||
if (body) fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
|
||||
query = Object.assign(query || {}, { token: this.token_ });
|
||||
|
||||
let queryString = '';
|
||||
if (query) {
|
||||
const s = [];
|
||||
|
@ -19,6 +19,7 @@ const defaultState = {
|
||||
selectedFolderId: null,
|
||||
env: 'prod',
|
||||
isProbablyReaderable: true,
|
||||
authStatus: 'starting',
|
||||
};
|
||||
|
||||
const reduxMiddleware = store => next => async (action) => {
|
||||
@ -94,6 +95,11 @@ function reducer(state = defaultState, action) {
|
||||
newState = Object.assign({}, state);
|
||||
newState.env = action.env;
|
||||
|
||||
} else if (action.type === 'AUTH_STATE_SET') {
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.authStatus = action.value;
|
||||
|
||||
}
|
||||
|
||||
return newState;
|
||||
|
@ -35,7 +35,7 @@ import Tag from '@joplin/lib/models/Tag';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
const packageInfo = require('./packageInfo.js');
|
||||
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
|
||||
const ClipperServer = require('@joplin/lib/ClipperServer');
|
||||
import ClipperServer from '@joplin/lib/ClipperServer';
|
||||
const { webFrame } = require('electron');
|
||||
const Menu = bridge().Menu;
|
||||
const PluginManager = require('@joplin/lib/services/PluginManager');
|
||||
@ -757,7 +757,7 @@ class Application extends BaseApplication {
|
||||
ClipperServer.instance().setDispatch(this.store().dispatch);
|
||||
|
||||
if (Setting.value('clipperServer.autoStart')) {
|
||||
ClipperServer.instance().start();
|
||||
void ClipperServer.instance().start();
|
||||
}
|
||||
|
||||
ExternalEditWatcher.instance().setLogger(reg.logger());
|
||||
|
@ -3,7 +3,7 @@ const { connect } = require('react-redux');
|
||||
const bridge = require('electron').remote.require('./bridge').default;
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
const ClipperServer = require('@joplin/lib/ClipperServer');
|
||||
const ClipperServer = require('@joplin/lib/ClipperServer').default;
|
||||
const Setting = require('@joplin/lib/models/Setting').default;
|
||||
const { clipboard } = require('electron');
|
||||
const ExtensionBadge = require('./ExtensionBadge.min');
|
||||
@ -44,7 +44,7 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
if (confirm(_('Are you sure you want to renew the authorisation token?'))) {
|
||||
void EncryptionService.instance()
|
||||
.generateApiToken()
|
||||
.then((token: string) => {
|
||||
.then((token) => {
|
||||
Setting.setValue('api.token', token);
|
||||
});
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ import { ShareInvitation } from '@joplin/lib/services/share/reducer';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems';
|
||||
import ClipperServer from '@joplin/lib/ClipperServer';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
const { PromptDialog } = require('../PromptDialog.min.js');
|
||||
@ -70,6 +71,7 @@ interface Props {
|
||||
startupPluginsLoaded: boolean;
|
||||
shareInvitations: ShareInvitation[];
|
||||
isSafeMode: boolean;
|
||||
needApiAuth: boolean;
|
||||
}
|
||||
|
||||
interface ShareFolderDialogOptions {
|
||||
@ -568,9 +570,24 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
void reg.scheduleSync(1000);
|
||||
};
|
||||
|
||||
const onApiGrantAuthorization = (accept: boolean) => {
|
||||
ClipperServer.instance().api.acceptAuthToken(accept);
|
||||
};
|
||||
|
||||
let msg = null;
|
||||
|
||||
if (this.props.isSafeMode) {
|
||||
// When adding something here, don't forget to update the condition in
|
||||
// this.messageBoxVisible()
|
||||
|
||||
if (this.props.needApiAuth) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('The Web Clipper needs your authorisation to access your data.'),
|
||||
_('Grant authorisation'),
|
||||
() => onApiGrantAuthorization(true),
|
||||
_('Reject'),
|
||||
() => onApiGrantAuthorization(false)
|
||||
);
|
||||
} else if (this.props.isSafeMode) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('Safe mode is currently active. Note rendering and all plugins are temporarily disabled.'),
|
||||
_('Disable safe mode and restart'),
|
||||
@ -634,7 +651,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
messageBoxVisible(props: Props = null) {
|
||||
if (!props) props = this.props;
|
||||
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode || this.showShareInvitationNotification(props);
|
||||
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode || this.showShareInvitationNotification(props) || this.props.needApiAuth;
|
||||
}
|
||||
|
||||
registerCommands() {
|
||||
@ -860,6 +877,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
startupPluginsLoaded: state.startupPluginsLoaded,
|
||||
shareInvitations: state.shareService.shareInvitations,
|
||||
isSafeMode: state.settings.isSafeMode,
|
||||
needApiAuth: state.needApiAuth,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,18 +1,31 @@
|
||||
import Setting from './models/Setting';
|
||||
import Logger from './Logger';
|
||||
import Api, { RequestFile } from './services/rest/Api';
|
||||
import ApiResponse from './services/rest/ApiResponse';
|
||||
const urlParser = require('url');
|
||||
const Setting = require('./models/Setting').default;
|
||||
const Logger = require('./Logger').default;
|
||||
const { randomClipperPort, startPort } = require('./randomClipperPort');
|
||||
const enableServerDestroy = require('server-destroy');
|
||||
const Api = require('./services/rest/Api').default;
|
||||
const ApiResponse = require('./services/rest/ApiResponse').default;
|
||||
const multiparty = require('multiparty');
|
||||
|
||||
class ClipperServer {
|
||||
export enum StartState {
|
||||
Idle = 'idle',
|
||||
Starting = 'starting',
|
||||
Started = 'started',
|
||||
}
|
||||
|
||||
export default class ClipperServer {
|
||||
|
||||
private logger_: Logger;
|
||||
private startState_: StartState = StartState.Idle;
|
||||
private server_: any = null;
|
||||
private port_: number = null;
|
||||
private api_: Api = null;
|
||||
private dispatch_: Function;
|
||||
|
||||
private static instance_: ClipperServer = null;
|
||||
|
||||
constructor() {
|
||||
this.logger_ = new Logger();
|
||||
this.startState_ = 'idle';
|
||||
this.server_ = null;
|
||||
this.port_ = null;
|
||||
}
|
||||
|
||||
static instance() {
|
||||
@ -21,13 +34,17 @@ class ClipperServer {
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
initialize(actionApi = null) {
|
||||
this.api_ = new Api(() => {
|
||||
return Setting.value('api.token');
|
||||
}, actionApi);
|
||||
public get api(): Api {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
setLogger(l) {
|
||||
initialize(actionApi: any = null) {
|
||||
this.api_ = new Api(() => {
|
||||
return Setting.value('api.token');
|
||||
}, (action: any) => { this.dispatch(action); }, actionApi);
|
||||
}
|
||||
|
||||
setLogger(l: Logger) {
|
||||
this.logger_ = l;
|
||||
}
|
||||
|
||||
@ -35,16 +52,16 @@ class ClipperServer {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
setDispatch(d) {
|
||||
setDispatch(d: Function) {
|
||||
this.dispatch_ = d;
|
||||
}
|
||||
|
||||
dispatch(action) {
|
||||
dispatch(action: any) {
|
||||
if (!this.dispatch_) throw new Error('dispatch not set!');
|
||||
this.dispatch_(action);
|
||||
}
|
||||
|
||||
setStartState(v) {
|
||||
setStartState(v: StartState) {
|
||||
if (this.startState_ === v) return;
|
||||
this.startState_ = v;
|
||||
this.dispatch({
|
||||
@ -53,7 +70,7 @@ class ClipperServer {
|
||||
});
|
||||
}
|
||||
|
||||
setPort(v) {
|
||||
setPort(v: number) {
|
||||
if (this.port_ === v) return;
|
||||
this.port_ = v;
|
||||
this.dispatch({
|
||||
@ -85,7 +102,7 @@ class ClipperServer {
|
||||
async start() {
|
||||
this.setPort(null);
|
||||
|
||||
this.setStartState('starting');
|
||||
this.setStartState(StartState.Starting);
|
||||
|
||||
const settingPort = Setting.value('api.port');
|
||||
|
||||
@ -93,15 +110,15 @@ class ClipperServer {
|
||||
const p = settingPort ? settingPort : await this.findAvailablePort();
|
||||
this.setPort(p);
|
||||
} catch (error) {
|
||||
this.setStartState('idle');
|
||||
this.setStartState(StartState.Idle);
|
||||
this.logger().error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.server_ = require('http').createServer();
|
||||
|
||||
this.server_.on('request', async (request, response) => {
|
||||
const writeCorsHeaders = (code, contentType = 'application/json', additionalHeaders = null) => {
|
||||
this.server_.on('request', async (request: any, response: any) => {
|
||||
const writeCorsHeaders = (code: any, contentType = 'application/json', additionalHeaders: any = null) => {
|
||||
const headers = Object.assign(
|
||||
{},
|
||||
{
|
||||
@ -115,19 +132,19 @@ class ClipperServer {
|
||||
response.writeHead(code, headers);
|
||||
};
|
||||
|
||||
const writeResponseJson = (code, object) => {
|
||||
const writeResponseJson = (code: any, object: any) => {
|
||||
writeCorsHeaders(code);
|
||||
response.write(JSON.stringify(object));
|
||||
response.end();
|
||||
};
|
||||
|
||||
const writeResponseText = (code, text) => {
|
||||
const writeResponseText = (code: any, text: any) => {
|
||||
writeCorsHeaders(code, 'text/plain');
|
||||
response.write(text);
|
||||
response.end();
|
||||
};
|
||||
|
||||
const writeResponseInstance = (code, instance) => {
|
||||
const writeResponseInstance = (code: any, instance: any) => {
|
||||
if (instance.type === 'attachment') {
|
||||
const filename = instance.attachmentFilename ? instance.attachmentFilename : 'file';
|
||||
writeCorsHeaders(code, instance.contentType ? instance.contentType : 'application/octet-stream', {
|
||||
@ -140,7 +157,7 @@ class ClipperServer {
|
||||
}
|
||||
};
|
||||
|
||||
const writeResponse = (code, response) => {
|
||||
const writeResponse = (code: any, response: any) => {
|
||||
if (response instanceof ApiResponse) {
|
||||
writeResponseInstance(code, response);
|
||||
} else if (typeof response === 'string') {
|
||||
@ -156,7 +173,7 @@ class ClipperServer {
|
||||
|
||||
const url = urlParser.parse(request.url, true);
|
||||
|
||||
const execRequest = async (request, body = '', files = []) => {
|
||||
const execRequest = async (request: any, body = '', files: RequestFile[] = []) => {
|
||||
try {
|
||||
const response = await this.api_.route(request.method, url.pathname, url.query, body, files);
|
||||
writeResponse(200, response);
|
||||
@ -181,27 +198,27 @@ class ClipperServer {
|
||||
if (contentType.indexOf('multipart/form-data') === 0) {
|
||||
const form = new multiparty.Form();
|
||||
|
||||
form.parse(request, function(error, fields, files) {
|
||||
form.parse(request, function(error: any, fields: any, files: any) {
|
||||
if (error) {
|
||||
writeResponse(error.httpCode ? error.httpCode : 500, error.message);
|
||||
return;
|
||||
} else {
|
||||
execRequest(request, fields && fields.props && fields.props.length ? fields.props[0] : '', files && files.data ? files.data : []);
|
||||
void execRequest(request, fields && fields.props && fields.props.length ? fields.props[0] : '', files && files.data ? files.data : []);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (request.method === 'POST' || request.method === 'PUT') {
|
||||
let body = '';
|
||||
|
||||
request.on('data', data => {
|
||||
request.on('data', (data: any) => {
|
||||
body += data;
|
||||
});
|
||||
|
||||
request.on('end', async () => {
|
||||
execRequest(request, body);
|
||||
void execRequest(request, body);
|
||||
});
|
||||
} else {
|
||||
execRequest(request);
|
||||
void execRequest(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -213,7 +230,7 @@ class ClipperServer {
|
||||
|
||||
this.server_.listen(this.port_, '127.0.0.1');
|
||||
|
||||
this.setStartState('started');
|
||||
this.setStartState(StartState.Started);
|
||||
|
||||
// We return an empty promise that never resolves so that it's possible to `await` the server indefinitely.
|
||||
// This is used only in command-server.js
|
||||
@ -223,9 +240,7 @@ class ClipperServer {
|
||||
async stop() {
|
||||
this.server_.destroy();
|
||||
this.server_ = null;
|
||||
this.setStartState('idle');
|
||||
this.setStartState(StartState.Idle);
|
||||
this.setPort(null);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClipperServer;
|
@ -91,6 +91,7 @@ export interface State {
|
||||
editorNoteStatuses: any;
|
||||
isInsertingNotes: boolean;
|
||||
hasEncryptedItems: boolean;
|
||||
needApiAuth: boolean;
|
||||
|
||||
// Extra reducer keys go here:
|
||||
pluginService: PluginServiceState;
|
||||
@ -160,6 +161,7 @@ export const defaultState: State = {
|
||||
editorNoteStatuses: {},
|
||||
isInsertingNotes: false,
|
||||
hasEncryptedItems: false,
|
||||
needApiAuth: false,
|
||||
|
||||
pluginService: pluginServiceDefaultState,
|
||||
shareService: shareServiceDefaultState,
|
||||
@ -1137,6 +1139,11 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
||||
draft.pluginsLegacy = newPluginsLegacy;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'API_NEED_AUTH_SET':
|
||||
draft.needApiAuth = action.value;
|
||||
break;
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
|
@ -8,6 +8,7 @@ import route_tags from './routes/tags';
|
||||
import route_master_keys from './routes/master_keys';
|
||||
import route_search from './routes/search';
|
||||
import route_ping from './routes/ping';
|
||||
import route_auth from './routes/auth';
|
||||
|
||||
const { ltrimSlashes } = require('../../path-utils');
|
||||
const md5 = require('md5');
|
||||
@ -19,7 +20,7 @@ export enum RequestMethod {
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
interface RequestFile {
|
||||
export interface RequestFile {
|
||||
path: string;
|
||||
}
|
||||
|
||||
@ -39,6 +40,9 @@ interface RequestQuery {
|
||||
limit?: number;
|
||||
order_dir?: PaginationOrderDir;
|
||||
order_by?: string;
|
||||
|
||||
// Auth token
|
||||
auth_token?: string;
|
||||
}
|
||||
|
||||
export interface Request {
|
||||
@ -53,7 +57,24 @@ export interface Request {
|
||||
action?: any;
|
||||
}
|
||||
|
||||
type RouteFunction = (request: Request, id: string, link: string)=> Promise<any | void>;
|
||||
export enum AuthTokenStatus {
|
||||
Waiting = 'waiting',
|
||||
Accepted = 'accepted',
|
||||
Rejected = 'rejected',
|
||||
}
|
||||
|
||||
interface AuthToken {
|
||||
value: string;
|
||||
status: AuthTokenStatus;
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
dispatch: Function;
|
||||
authToken: AuthToken;
|
||||
token: string;
|
||||
}
|
||||
|
||||
type RouteFunction = (request: Request, id: string, link: string, context: RequestContext)=> Promise<any | void>;
|
||||
|
||||
interface ResourceNameToRoute {
|
||||
[key: string]: RouteFunction;
|
||||
@ -62,13 +83,16 @@ interface ResourceNameToRoute {
|
||||
export default class Api {
|
||||
|
||||
private token_: string | Function;
|
||||
private authToken_: AuthToken = null;
|
||||
private knownNounces_: any = {};
|
||||
private actionApi_: any;
|
||||
private resourceNameToRoute_: ResourceNameToRoute = {};
|
||||
private dispatch_: Function;
|
||||
|
||||
public constructor(token: string = null, actionApi: any = null) {
|
||||
public constructor(token: string | Function = null, dispatch: Function = null, actionApi: any = null) {
|
||||
this.token_ = token;
|
||||
this.actionApi_ = actionApi;
|
||||
this.dispatch_ = dispatch;
|
||||
|
||||
this.resourceNameToRoute_ = {
|
||||
ping: route_ping,
|
||||
@ -79,13 +103,43 @@ export default class Api {
|
||||
master_keys: route_master_keys,
|
||||
search: route_search,
|
||||
services: this.action_services.bind(this),
|
||||
auth: route_auth,
|
||||
};
|
||||
|
||||
this.dispatch = this.dispatch.bind(this);
|
||||
}
|
||||
|
||||
public get token() {
|
||||
public get token(): string {
|
||||
return typeof this.token_ === 'function' ? this.token_() : this.token_;
|
||||
}
|
||||
|
||||
private dispatch(action: any) {
|
||||
if (action.type === 'API_AUTH_TOKEN_SET') {
|
||||
this.authToken_ = {
|
||||
value: action.value,
|
||||
status: AuthTokenStatus.Waiting,
|
||||
};
|
||||
|
||||
this.dispatch_({
|
||||
type: 'API_NEED_AUTH_SET',
|
||||
value: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return this.dispatch_(action);
|
||||
}
|
||||
|
||||
public acceptAuthToken(accept: boolean) {
|
||||
this.authToken_.status = accept ? AuthTokenStatus.Accepted : AuthTokenStatus.Rejected;
|
||||
|
||||
this.dispatch_({
|
||||
type: 'API_NEED_AUTH_SET',
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
|
||||
private parsePath(path: string) {
|
||||
path = ltrimSlashes(path);
|
||||
if (!path) return { fn: null, params: [] };
|
||||
@ -155,8 +209,14 @@ export default class Api {
|
||||
|
||||
this.checkToken_(request);
|
||||
|
||||
const context: RequestContext = {
|
||||
dispatch: this.dispatch,
|
||||
token: this.token,
|
||||
authToken: this.authToken_,
|
||||
};
|
||||
|
||||
try {
|
||||
return await parsedPath.fn(request, id, link);
|
||||
return await parsedPath.fn(request, id, link, context);
|
||||
} catch (error) {
|
||||
if (!error.httpCode) error.httpCode = 500;
|
||||
throw error;
|
||||
@ -166,13 +226,23 @@ export default class Api {
|
||||
private checkToken_(request: Request) {
|
||||
// For now, whitelist some calls to allow the web clipper to work
|
||||
// without an extra auth step
|
||||
const whiteList = [['GET', 'ping'], ['GET', 'tags'], ['GET', 'folders'], ['POST', 'notes']];
|
||||
// const whiteList = [['GET', 'ping'], ['GET', 'tags'], ['GET', 'folders'], ['POST', 'notes']];
|
||||
|
||||
const whiteList = [
|
||||
['GET', 'ping'],
|
||||
['GET', 'auth'],
|
||||
['POST', 'auth'],
|
||||
['GET', 'auth/check'],
|
||||
];
|
||||
|
||||
for (let i = 0; i < whiteList.length; i++) {
|
||||
if (whiteList[i][0] === request.method && whiteList[i][1] === request.path) return;
|
||||
}
|
||||
|
||||
// If the API has been initialized without a token, it means no auth is
|
||||
// needed. This is for example when it is used as the plugin data API.
|
||||
if (!this.token) return;
|
||||
|
||||
if (!request.query || !request.query.token) throw new ErrorForbidden('Missing "token" parameter');
|
||||
if (request.query.token !== this.token) throw new ErrorForbidden('Invalid "token" parameter');
|
||||
}
|
||||
|
53
packages/lib/services/rest/routes/auth.ts
Normal file
53
packages/lib/services/rest/routes/auth.ts
Normal file
@ -0,0 +1,53 @@
|
||||
|
||||
import { AuthTokenStatus, Request, RequestContext } from '../Api';
|
||||
import uuid from '../../../uuid';
|
||||
|
||||
let authToken: string = null;
|
||||
|
||||
export default async function(request: Request, id: string = null, _link: string = null, context: RequestContext = null) {
|
||||
if (request.method === 'POST') {
|
||||
authToken = uuid.createNano();
|
||||
|
||||
context.dispatch({
|
||||
type: 'API_AUTH_TOKEN_SET',
|
||||
value: authToken,
|
||||
});
|
||||
|
||||
return { auth_token: authToken };
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
if (id === 'check') {
|
||||
if ('auth_token' in request.query) {
|
||||
if (request.query.auth_token === context.authToken.value) {
|
||||
const output: any = {
|
||||
status: context.authToken.status,
|
||||
};
|
||||
|
||||
if (context.authToken.status === AuthTokenStatus.Accepted) {
|
||||
output.token = context.token;
|
||||
}
|
||||
|
||||
return output;
|
||||
} else {
|
||||
throw new Error(`Invalid auth token: ${request.query.auth_token}`);
|
||||
}
|
||||
}
|
||||
|
||||
if ('token' in request.query) {
|
||||
const isValid = request.query.token === context.token;
|
||||
|
||||
if (isValid) {
|
||||
context.dispatch({
|
||||
type: 'API_AUTH_LOGIN',
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { valid: isValid };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid request');
|
||||
}
|
@ -9,10 +9,10 @@ const { customAlphabet } = require('nanoid/non-secure');
|
||||
const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 22);
|
||||
|
||||
export default {
|
||||
create: function() {
|
||||
create: function(): string {
|
||||
return createUuidV4().replace(/-/g, '');
|
||||
},
|
||||
createNano: function() {
|
||||
createNano: function(): string {
|
||||
return nanoid();
|
||||
},
|
||||
};
|
||||
|
25
readme/spec/clipper_auth.md
Normal file
25
readme/spec/clipper_auth.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Clipper authorisation mechanism
|
||||
|
||||
In order to access the clipper API, the client must use a token, which is a random string generated by the application.
|
||||
|
||||
There are two ways for applications to obtain this token:
|
||||
|
||||
## Get it from the user
|
||||
|
||||
The user can copy the token in the Clipper configuration page and provide it directly to the application. This is the simplest method.
|
||||
|
||||
## Request it programmatically
|
||||
|
||||
The token can also be requested programmatically, as is done for the web clipper extension. It works as below:
|
||||
|
||||
- The client calls `POST /auth`. The server responds with `{ auth_token: "AUTH_TOKEN" }`. This `auth_token` is different from the regular token - it is just used to authentify the client.
|
||||
|
||||
- The application displays a message asking the user to Accept or Reject the access request.
|
||||
|
||||
- The clients calls `GET /auth/check?auth_token=AUTH_TOKEN` at regular intervals. Initially the server responds with `{ status: "waiting" }`, since we are waiting for the user to confirm or reject.
|
||||
|
||||
- Once the user accepts the request in the application, the server returns `{ status: "accepted", token: "API_TOKEN" }`.
|
||||
|
||||
- The client can now use this `API_TOKEN` to make requests.
|
||||
|
||||
- If the users rejects the request, the server returns `{ status: "rejected" }`, and the client can display an error message.
|
Loading…
Reference in New Issue
Block a user