You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop, Clipper: Web Clipper now must request authorisation before accessing the application data
This commit is contained in:
		| @@ -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. | ||||
		Reference in New Issue
	
	Block a user