diff --git a/.eslintignore b/.eslintignore index 53f0c3eb9..f81c2caa6 100644 --- a/.eslintignore +++ b/.eslintignore @@ -152,6 +152,7 @@ ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js +ReactNativeClient/lib/ntpDate.js ReactNativeClient/lib/services/CommandService.js ReactNativeClient/lib/services/keychain/KeychainService.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.dummy.js diff --git a/.gitignore b/.gitignore index ed7e1dc49..ecdc4c341 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,7 @@ ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js +ReactNativeClient/lib/ntpDate.js ReactNativeClient/lib/services/CommandService.js ReactNativeClient/lib/services/keychain/KeychainService.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.dummy.js diff --git a/ElectronClient/package-lock.json b/ElectronClient/package-lock.json index 75d908eb8..2d6b5fff1 100644 --- a/ElectronClient/package-lock.json +++ b/ElectronClient/package-lock.json @@ -1,6 +1,6 @@ { "name": "Joplin", - "version": "1.0.242", + "version": "1.0.245", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/ElectronClient/package.json b/ElectronClient/package.json index 07cf88b74..f1e58c9cf 100644 --- a/ElectronClient/package.json +++ b/ElectronClient/package.json @@ -1,6 +1,6 @@ { "name": "Joplin", - "version": "1.0.242", + "version": "1.0.245", "description": "Joplin for Desktop", "main": "main.js", "scripts": { diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index c8419a5ce..f9deaa5bd 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -44,6 +44,9 @@ const KvStore = require('lib/services/KvStore'); const MigrationService = require('lib/services/MigrationService'); const { toSystemSlashes } = require('lib/path-utils.js'); +const ntpClient = require('lib/vendor/ntp-client'); +ntpClient.dgram = require('dgram'); + class BaseApplication { constructor() { this.logger_ = new Logger(); @@ -673,6 +676,7 @@ class BaseApplication { reg.dispatch = () => {}; BaseService.logger_ = this.logger_; + require('lib/ntpDate').setLogger(reg.logger()); this.dbLogger_.addTarget('file', { path: `${profileDir}/log-database.txt` }); this.dbLogger_.setLevel(initArgs.logLevel); diff --git a/ReactNativeClient/lib/file-api-driver-dropbox.js b/ReactNativeClient/lib/file-api-driver-dropbox.js index 876208821..f16c2dd33 100644 --- a/ReactNativeClient/lib/file-api-driver-dropbox.js +++ b/ReactNativeClient/lib/file-api-driver-dropbox.js @@ -44,7 +44,7 @@ class FileApiDriverDropbox { metadataToStat_(md, path) { const output = { path: path, - updated_time: md.server_modified ? new Date(md.server_modified) : new Date(), + updated_time: md.server_modified ? (new Date(md.server_modified)).getTime() : Date.now(), isDir: md['.tag'] === 'folder', }; diff --git a/ReactNativeClient/lib/file-api.js b/ReactNativeClient/lib/file-api.js index f53e97ff5..f188eac60 100644 --- a/ReactNativeClient/lib/file-api.js +++ b/ReactNativeClient/lib/file-api.js @@ -6,6 +6,7 @@ const JoplinError = require('lib/JoplinError'); const ArrayUtils = require('lib/ArrayUtils'); const { time } = require('lib/time-utils.js'); const { sprintf } = require('sprintf-js'); +const Mutex = require('async-mutex').Mutex; function requestCanBeRepeated(error) { const errorCode = typeof error === 'object' && error.code ? error.code : null; @@ -57,6 +58,65 @@ class FileApi { this.tempDirName_ = null; this.driver_.fileApi_ = this; this.requestRepeatCount_ = null; // For testing purpose only - normally this value should come from the driver + this.remoteDateOffset_ = 0; + this.remoteDateNextCheckTime_ = 0; + this.remoteDateMutex_ = new Mutex(); + } + + + async fetchRemoteDateOffset_() { + const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`; + const startTime = Date.now(); + await this.put(tempFile, 'timeCheck'); + + // Normally it should be possible to read the file back immediately but + // just in case, read it in a loop. + const loopStartTime = Date.now(); + let stat = null; + while (Date.now() - loopStartTime < 5000) { + stat = await this.stat(tempFile); + if (stat) break; + await time.msleep(200); + } + + if (!stat) throw new Error('Timed out trying to get sync target clock time'); + + this.delete(tempFile); // No need to await for this call + + const endTime = Date.now(); + const expectedTime = Math.round((endTime + startTime) / 2); + return stat.updated_time - expectedTime; + } + + // Approximates the current time on the sync target. It caches the time offset to + // improve performance. + async remoteDate() { + const shouldSyncTime = () => { + return !this.remoteDateNextCheckTime_ || Date.now() > this.remoteDateNextCheckTime_; + }; + + if (shouldSyncTime()) { + const release = await this.remoteDateMutex_.acquire(); + + try { + // Another call might have refreshed the time while we were waiting for the mutex, + // so check again if we need to refresh. + if (shouldSyncTime()) { + this.remoteDateOffset_ = await this.fetchRemoteDateOffset_(); + // The sync target clock should rarely change but the device one might, + // so we need to refresh relatively frequently. + this.remoteDateNextCheckTime_ = Date.now() + 10 * 60 * 1000; + } + } catch (error) { + this.logger().warn('Could not retrieve remote date - defaulting to device date:', error); + this.remoteDateOffset_ = 0; + this.remoteDateNextCheckTime_ = Date.now() + 60 * 1000; + } finally { + release(); + } + } + + return new Date(Date.now() + this.remoteDateOffset_); } // Ideally all requests repeating should be done at the FileApi level to remove duplicate code in the drivers, but diff --git a/ReactNativeClient/lib/ntpDate.ts b/ReactNativeClient/lib/ntpDate.ts new file mode 100644 index 000000000..554c4a613 --- /dev/null +++ b/ReactNativeClient/lib/ntpDate.ts @@ -0,0 +1,59 @@ +const ntpClient = require('lib/vendor/ntp-client'); +const { Logger } = require('lib/logger.js'); +const Mutex = require('async-mutex').Mutex; + +let nextSyncTime = 0; +let timeOffset = 0; +let logger = new Logger(); + +const fetchingTimeMutex = new Mutex(); + +const server = { + domain: 'pool.ntp.org', + port: 123, +}; + +async function networkTime():Promise { + return new Promise(function(resolve:Function, reject:Function) { + ntpClient.getNetworkTime(server.domain, server.port, function(error:any, date:Date) { + if (error) { + reject(error); + return; + } + + resolve(date); + }); + }); +} + +function shouldSyncTime() { + return !nextSyncTime || Date.now() > nextSyncTime; +} + +export function setLogger(v:any) { + logger = v; +} + +export default async function():Promise { + if (shouldSyncTime()) { + const release = await fetchingTimeMutex.acquire(); + + try { + if (shouldSyncTime()) { + const date = await networkTime(); + nextSyncTime = Date.now() + 60 * 1000; + timeOffset = date.getTime() - Date.now(); + } + } catch (error) { + logger.warn('Could not get NTP time - falling back to device time:', error); + // Fallback to device time since + // most of the time it's actually correct + nextSyncTime = Date.now() + 20 * 1000; + timeOffset = 0; + } finally { + release(); + } + } + + return new Date(Date.now() + timeOffset); +} diff --git a/ReactNativeClient/lib/services/synchronizer/LockHandler.ts b/ReactNativeClient/lib/services/synchronizer/LockHandler.ts index 7148f9534..17e955a56 100644 --- a/ReactNativeClient/lib/services/synchronizer/LockHandler.ts +++ b/ReactNativeClient/lib/services/synchronizer/LockHandler.ts @@ -1,4 +1,5 @@ import { Dirnames } from './utils/types'; + const JoplinError = require('lib/JoplinError'); const { time } = require('lib/time-utils'); const { fileExtension, filename } = require('lib/path-utils.js'); @@ -98,8 +99,8 @@ export default class LockHandler { return output; } - private lockIsActive(lock:Lock):boolean { - return Date.now() - lock.updatedTime < this.lockTtl; + private lockIsActive(lock:Lock, currentDate:Date):boolean { + return currentDate.getTime() - lock.updatedTime < this.lockTtl; } async hasActiveLock(lockType:LockType, clientType:string = null, clientId:string = null) { @@ -112,11 +113,12 @@ export default class LockHandler { // of that type instead. async activeLock(lockType:LockType, clientType:string = null, clientId:string = null) { const locks = await this.locks(lockType); + const currentDate = await this.api_.remoteDate(); if (lockType === LockType.Exclusive) { const activeLocks = locks .slice() - .filter((lock:Lock) => this.lockIsActive(lock)) + .filter((lock:Lock) => this.lockIsActive(lock, currentDate)) .sort((a:Lock, b:Lock) => { if (a.updatedTime === b.updatedTime) { return a.clientId < b.clientId ? -1 : +1; @@ -134,7 +136,7 @@ export default class LockHandler { for (const lock of locks) { if (clientType && lock.clientType !== clientType) continue; if (clientId && lock.clientId !== clientId) continue; - if (this.lockIsActive(lock)) return lock; + if (this.lockIsActive(lock, currentDate)) return lock; } return null; } diff --git a/ReactNativeClient/lib/vendor/ntp-client.js b/ReactNativeClient/lib/vendor/ntp-client.js new file mode 100644 index 000000000..235d48d7b --- /dev/null +++ b/ReactNativeClient/lib/vendor/ntp-client.js @@ -0,0 +1,155 @@ +/* + * ntp-client + * https://github.com/moonpyk/node-ntp-client + * + * Copyright (c) 2013 Clément Bourgeois + * Licensed under the MIT license. + */ + +// ---------------------------------------------------------------------------------------- +// 2020-08-09: We vendor the package because although it works +// it has several bugs and is currently unmaintained +// ---------------------------------------------------------------------------------------- + +const Buffer = require('buffer').Buffer; + +(function (exports) { + "use strict"; + + exports.defaultNtpPort = 123; + exports.defaultNtpServer = "pool.ntp.org"; + + exports.dgram = null; + + /** + * Amount of acceptable time to await for a response from the remote server. + * Configured default to 10 seconds. + */ + exports.ntpReplyTimeout = 10000; + + /** + * Fetches the current NTP Time from the given server and port. + * @param {string} server IP/Hostname of the remote NTP Server + * @param {number} port Remote NTP Server port number + * @param {function(Object, Date)} callback(err, date) Async callback for + * the result date or eventually error. + */ + exports.getNetworkTime = function (server, port, callback) { + if (callback === null || typeof callback !== "function") { + return; + } + + server = server || exports.defaultNtpServer; + port = port || exports.defaultNtpPort; + + if (!exports.dgram) throw new Error('dgram package has not been set!!'); + + var client = exports.dgram.createSocket("udp4"), + ntpData = new Buffer(48); + + // RFC 2030 -> LI = 0 (no warning, 2 bits), VN = 3 (IPv4 only, 3 bits), Mode = 3 (Client Mode, 3 bits) -> 1 byte + // -> rtol(LI, 6) ^ rotl(VN, 3) ^ rotl(Mode, 0) + // -> = 0x00 ^ 0x18 ^ 0x03 + ntpData[0] = 0x1B; + + for (var i = 1; i < 48; i++) { + ntpData[i] = 0; + } + + // Some errors can happen before/after send() or cause send() to was impossible. + // Some errors will also be given to the send() callback. + // We keep a flag, therefore, to prevent multiple callbacks. + // NOTE : the error callback is not generalised, as the client has to lose the connection also, apparently. + var errorFired = false; + + function closeClient(client) { + try { + client.close(); + } catch (error) { + // Doesn't mater if it could not be closed + } + } + + var timeout = setTimeout(function () { + closeClient(client); + + if (errorFired) { + return; + } + callback(new Error("Timeout waiting for NTP response."), null); + errorFired = true; + }, exports.ntpReplyTimeout); + + client.on('error', function (err) { + clearTimeout(timeout); + + if (errorFired) { + return; + } + + callback(err, null); + errorFired = true; + }); + + // NOTE: To make it work in React Native (Android), a port need to be bound + // before calling client.send() + + // client.bind(5555, '0.0.0.0', function() { + client.send(ntpData, 0, ntpData.length, port, server, function (err) { + if (err) { + clearTimeout(timeout); + if (errorFired) { + return; + } + callback(err, null); + errorFired = true; + closeClient(client); + return; + } + + client.once('message', function (msg) { + clearTimeout(timeout); + closeClient(client); + + // Offset to get to the "Transmit Timestamp" field (time at which the reply + // departed the server for the client, in 64-bit timestamp format." + var offsetTransmitTime = 40, + intpart = 0, + fractpart = 0; + + // Get the seconds part + for (var i = 0; i <= 3; i++) { + intpart = 256 * intpart + msg[offsetTransmitTime + i]; + } + + // Get the seconds fraction + for (i = 4; i <= 7; i++) { + fractpart = 256 * fractpart + msg[offsetTransmitTime + i]; + } + + var milliseconds = (intpart * 1000 + (fractpart * 1000) / 0x100000000); + + // **UTC** time + var date = new Date("Jan 01 1900 GMT"); + date.setUTCMilliseconds(date.getUTCMilliseconds() + milliseconds); + + callback(null, date); + }); + }); + // }); + }; + + exports.demo = function (argv) { + exports.getNetworkTime( + exports.defaultNtpServer, + exports.defaultNtpPort, + function (err, date) { + if (err) { + console.error(err); + return; + } + + console.log(date); + }); + }; +}(exports)); \ No newline at end of file diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index 3ebccdbfe..298252148 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -66,6 +66,9 @@ const WelcomeUtils = require('lib/WelcomeUtils'); const { themeStyle } = require('lib/components/global-style.js'); const { uuid } = require('lib/uuid.js'); +const ntpClient = require('lib/vendor/ntp-client'); +ntpClient.dgram = require('react-native-udp'); + const { loadKeychainServiceAndSettings } = require('lib/services/SettingUtils'); const KeychainServiceDriverMobile = require('lib/services/keychain/KeychainServiceDriver.mobile').default; @@ -401,6 +404,7 @@ async function initialize(dispatch, messageHandler) { reg.setShowErrorMessageBoxHandler((message) => { alert(message); }); BaseService.logger_ = mainLogger; + require('lib/ntpDate').setLogger(reg.logger()); reg.logger().info('===================================='); reg.logger().info(`Starting application ${Setting.value('appId')} (${Setting.value('env')})`);