db.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. import {
  2. i18n, request, buffer2string, getFullUrl, isRemote, getRnd4,
  3. } from '#/common';
  4. import { objectGet, objectSet } from '#/common/object';
  5. import {
  6. getNameURI, parseMeta, newScript, getDefaultCustom,
  7. } from './script';
  8. import { testScript, testBlacklist } from './tester';
  9. import { register } from './init';
  10. import patchDB from './patch-db';
  11. import { setOption } from './options';
  12. import { sendMessageOrIgnore } from './message';
  13. import pluginEvents from '../plugin/events';
  14. function cacheOrFetch(handle) {
  15. const requests = {};
  16. return function cachedHandle(url, ...args) {
  17. let promise = requests[url];
  18. if (!promise) {
  19. promise = handle.call(this, url, ...args)
  20. .catch(err => {
  21. console.error(`Error fetching: ${url}`, err);
  22. })
  23. .then(() => {
  24. delete requests[url];
  25. });
  26. requests[url] = promise;
  27. }
  28. return promise;
  29. };
  30. }
  31. function ensureListArgs(handle) {
  32. return function handleList(data) {
  33. let items = Array.isArray(data) ? data : [data];
  34. items = items.filter(Boolean);
  35. if (!items.length) return Promise.resolve();
  36. return handle.call(this, items);
  37. };
  38. }
  39. const store = {};
  40. const storage = {
  41. base: {
  42. prefix: '',
  43. getKey(id) {
  44. return `${this.prefix}${id}`;
  45. },
  46. getOne(id) {
  47. const key = this.getKey(id);
  48. return browser.storage.local.get(key).then(data => data[key]);
  49. },
  50. getMulti(ids, def) {
  51. return browser.storage.local.get(ids.map(id => this.getKey(id)))
  52. .then(data => {
  53. const result = {};
  54. ids.forEach(id => { result[id] = data[this.getKey(id)] || def; });
  55. return result;
  56. });
  57. },
  58. set(id, value) {
  59. if (!id) return Promise.resolve();
  60. return browser.storage.local.set({
  61. [this.getKey(id)]: value,
  62. });
  63. },
  64. remove(id) {
  65. if (!id) return Promise.resolve();
  66. return browser.storage.local.remove(this.getKey(id));
  67. },
  68. removeMulti(ids) {
  69. return browser.storage.local.remove(ids.map(id => this.getKey(id)));
  70. },
  71. },
  72. };
  73. storage.script = Object.assign({}, storage.base, {
  74. prefix: 'scr:',
  75. dump: ensureListArgs(function dump(items) {
  76. const updates = {};
  77. items.forEach(item => {
  78. updates[this.getKey(item.props.id)] = item;
  79. store.scriptMap[item.props.id] = item;
  80. });
  81. return browser.storage.local.set(updates)
  82. .then(() => items);
  83. }),
  84. });
  85. storage.code = Object.assign({}, storage.base, {
  86. prefix: 'code:',
  87. });
  88. storage.value = Object.assign({}, storage.base, {
  89. prefix: 'val:',
  90. dump(dict) {
  91. const updates = {};
  92. Object.keys(dict)
  93. .forEach(id => {
  94. const value = dict[id];
  95. updates[this.getKey(id)] = value;
  96. });
  97. return browser.storage.local.set(updates);
  98. },
  99. });
  100. storage.require = Object.assign({}, storage.base, {
  101. prefix: 'req:',
  102. fetch: cacheOrFetch(function fetch(uri) {
  103. return request(uri).then(({ data }) => this.set(uri, data));
  104. }),
  105. });
  106. storage.cache = Object.assign({}, storage.base, {
  107. prefix: 'cac:',
  108. fetch: cacheOrFetch(function fetch(uri, check) {
  109. return request(uri, { responseType: 'arraybuffer' })
  110. .then(({ data: buffer, xhr }) => {
  111. const contentType = (xhr.getResponseHeader('content-type') || '').split(';')[0];
  112. const data = {
  113. contentType,
  114. buffer,
  115. blob: options => new Blob([buffer], Object.assign({ type: contentType }, options)),
  116. string: () => buffer2string(buffer),
  117. base64: () => window.btoa(data.string()),
  118. };
  119. return (check ? Promise.resolve(check(data)) : Promise.resolve())
  120. .then(() => this.set(uri, `${contentType},${data.base64()}`));
  121. });
  122. }),
  123. });
  124. register(initialize());
  125. function initialize() {
  126. return browser.storage.local.get('version')
  127. .then(({ version: lastVersion }) => {
  128. const { version } = browser.runtime.getManifest();
  129. return (lastVersion ? Promise.resolve() : patchDB())
  130. .then(() => {
  131. if (version !== lastVersion) return browser.storage.local.set({ version });
  132. });
  133. })
  134. .then(() => browser.storage.local.get())
  135. .then(data => {
  136. const scripts = [];
  137. const storeInfo = {
  138. id: 0,
  139. position: 0,
  140. };
  141. Object.keys(data).forEach(key => {
  142. const value = data[key];
  143. if (key.startsWith('scr:')) {
  144. // {
  145. // meta,
  146. // custom,
  147. // props: { id, position, uri },
  148. // config: { enabled, shouldUpdate },
  149. // }
  150. scripts.push(value);
  151. storeInfo.id = Math.max(storeInfo.id, getInt(objectGet(value, 'props.id')));
  152. storeInfo.position = Math.max(storeInfo.position, getInt(objectGet(value, 'props.position')));
  153. }
  154. });
  155. scripts.forEach(script => {
  156. script.custom = {
  157. ...getDefaultCustom(),
  158. ...script.custom,
  159. };
  160. });
  161. Object.assign(store, {
  162. scripts,
  163. storeInfo,
  164. scriptMap: scripts.reduce((map, item) => {
  165. map[item.props.id] = item;
  166. return map;
  167. }, {}),
  168. });
  169. if (process.env.DEBUG) {
  170. console.log('store:', store); // eslint-disable-line no-console
  171. }
  172. return sortScripts();
  173. });
  174. }
  175. function getInt(val) {
  176. return +val || 0;
  177. }
  178. function updateLastModified() {
  179. setOption('lastModified', Date.now());
  180. }
  181. export function normalizePosition() {
  182. const updates = [];
  183. const positionKey = 'props.position';
  184. store.scripts.forEach((item, index) => {
  185. const position = index + 1;
  186. if (objectGet(item, positionKey) !== position) {
  187. objectSet(item, positionKey, position);
  188. updates.push(item);
  189. }
  190. });
  191. store.storeInfo.position = store.scripts.length;
  192. const { length } = updates;
  193. if (!length) return Promise.resolve();
  194. return storage.script.dump(updates)
  195. .then(() => {
  196. updateLastModified();
  197. return length;
  198. });
  199. }
  200. export function sortScripts() {
  201. store.scripts.sort((a, b) => {
  202. const [pos1, pos2] = [a, b].map(item => getInt(objectGet(item, 'props.position')));
  203. return pos1 - pos2;
  204. });
  205. return normalizePosition()
  206. .then(changed => {
  207. sendMessageOrIgnore({ cmd: 'ScriptsUpdated' });
  208. return changed;
  209. });
  210. }
  211. export function getScript(where) {
  212. let script;
  213. if (where.id) {
  214. script = store.scriptMap[where.id];
  215. } else {
  216. const uri = where.uri || getNameURI({ meta: where.meta, id: '@@should-have-name' });
  217. const predicate = item => uri === objectGet(item, 'props.uri');
  218. script = store.scripts.find(predicate);
  219. }
  220. return Promise.resolve(script);
  221. }
  222. export function getScripts() {
  223. return Promise.resolve(store.scripts)
  224. .then(scripts => scripts.filter(script => !script.config.removed));
  225. }
  226. export function getScriptByIds(ids) {
  227. return Promise.all(ids.map(id => getScript({ id })))
  228. .then(scripts => scripts.filter(Boolean));
  229. }
  230. export function getScriptCode(id) {
  231. return storage.code.getOne(id);
  232. }
  233. /**
  234. * @desc Load values for batch updates.
  235. * @param {Array} ids
  236. */
  237. export function getValueStoresByIds(ids) {
  238. return storage.value.getMulti(ids);
  239. }
  240. /**
  241. * @desc Dump values for batch updates.
  242. * @param {Object} valueDict { id1: value1, id2: value2, ... }
  243. */
  244. export function dumpValueStores(valueDict) {
  245. if (process.env.DEBUG) {
  246. console.info('Update value stores', valueDict);
  247. }
  248. return storage.value.dump(valueDict).then(() => valueDict);
  249. }
  250. export function dumpValueStore(where, valueStore) {
  251. return (where.id
  252. ? Promise.resolve(where.id)
  253. : getScript(where).then(script => objectGet(script, 'props.id')))
  254. .then(id => {
  255. if (id) return dumpValueStores({ [id]: valueStore });
  256. });
  257. }
  258. /**
  259. * @desc Get scripts to be injected to page with specific URL.
  260. */
  261. export function getScriptsByURL(url) {
  262. const scripts = testBlacklist(url)
  263. ? []
  264. : store.scripts.filter(script => !script.config.removed && testScript(url, script));
  265. const reqKeys = {};
  266. const cacheKeys = {};
  267. scripts.forEach(script => {
  268. if (script.config.enabled) {
  269. if (!script.custom.pathMap) buildPathMap(script);
  270. const { pathMap } = script.custom;
  271. script.meta.require.forEach(key => {
  272. reqKeys[pathMap[key] || key] = 1;
  273. });
  274. Object.values(script.meta.resources).forEach(key => {
  275. cacheKeys[pathMap[key] || key] = 1;
  276. });
  277. }
  278. });
  279. const enabledScripts = scripts
  280. .filter(script => script.config.enabled);
  281. const gmValues = {
  282. GM_getValue: 1,
  283. GM_setValue: 1,
  284. GM_listValues: 1,
  285. GM_deleteValue: 1,
  286. };
  287. const scriptsWithValue = enabledScripts
  288. .filter(script => {
  289. const grant = objectGet(script, 'meta.grant');
  290. return grant && grant.some(gm => gmValues[gm]);
  291. });
  292. return Promise.all([
  293. storage.require.getMulti(Object.keys(reqKeys)),
  294. storage.cache.getMulti(Object.keys(cacheKeys)),
  295. storage.value.getMulti(scriptsWithValue.map(script => script.props.id), {}),
  296. storage.code.getMulti(enabledScripts.map(script => script.props.id)),
  297. ])
  298. .then(([require, cache, values, code]) => ({
  299. scripts,
  300. require,
  301. cache,
  302. values,
  303. code,
  304. }));
  305. }
  306. /**
  307. * @desc Get data for dashboard.
  308. */
  309. export function getData() {
  310. const cacheKeys = {};
  311. const { scripts } = store;
  312. scripts.forEach(script => {
  313. const icon = objectGet(script, 'meta.icon');
  314. if (isRemote(icon)) {
  315. const pathMap = objectGet(script, 'custom.pathMap') || {};
  316. const fullUrl = pathMap[icon] || icon;
  317. cacheKeys[fullUrl] = 1;
  318. }
  319. });
  320. return storage.cache.getMulti(Object.keys(cacheKeys))
  321. .then(cache => ({ scripts, cache }));
  322. }
  323. export function checkRemove() {
  324. const toRemove = store.scripts.filter(script => script.config.removed);
  325. if (toRemove.length) {
  326. store.scripts = store.scripts.filter(script => !script.config.removed);
  327. const ids = toRemove.map(script => script.props.id);
  328. storage.script.removeMulti(ids);
  329. storage.code.removeMulti(ids);
  330. storage.value.removeMulti(ids);
  331. }
  332. return Promise.resolve(toRemove.length);
  333. }
  334. export function removeScript(id) {
  335. const i = store.scripts.findIndex(item => id === objectGet(item, 'props.id'));
  336. if (i >= 0) {
  337. store.scripts.splice(i, 1);
  338. storage.script.remove(id);
  339. storage.code.remove(id);
  340. storage.value.remove(id);
  341. }
  342. sendMessageOrIgnore({
  343. cmd: 'RemoveScript',
  344. data: id,
  345. });
  346. return Promise.resolve();
  347. }
  348. export function moveScript(id, offset) {
  349. const index = store.scripts.findIndex(item => id === objectGet(item, 'props.id'));
  350. const step = offset > 0 ? 1 : -1;
  351. const indexStart = index;
  352. const indexEnd = index + offset;
  353. const offsetI = Math.min(indexStart, indexEnd);
  354. const offsetJ = Math.max(indexStart, indexEnd);
  355. const updated = store.scripts.slice(offsetI, offsetJ + 1);
  356. if (step > 0) {
  357. updated.push(updated.shift());
  358. } else {
  359. updated.unshift(updated.pop());
  360. }
  361. store.scripts = [
  362. ...store.scripts.slice(0, offsetI),
  363. ...updated,
  364. ...store.scripts.slice(offsetJ + 1),
  365. ];
  366. return normalizePosition();
  367. }
  368. function getUUID(id) {
  369. const idSec = (id + 0x10bde6a2).toString(16).slice(-8);
  370. return `${idSec}-${getRnd4()}-${getRnd4()}-${getRnd4()}-${getRnd4()}${getRnd4()}${getRnd4()}`;
  371. }
  372. function saveScript(script, code) {
  373. const config = script.config || {};
  374. config.enabled = getInt(config.enabled);
  375. config.shouldUpdate = getInt(config.shouldUpdate);
  376. const props = script.props || {};
  377. let oldScript;
  378. if (!props.id) {
  379. store.storeInfo.id += 1;
  380. props.id = store.storeInfo.id;
  381. } else {
  382. oldScript = store.scriptMap[props.id];
  383. }
  384. props.uri = getNameURI(script);
  385. props.uuid = props.uuid || getUUID(props.id);
  386. // Do not allow script with same name and namespace
  387. if (store.scripts.some(item => {
  388. const itemProps = item.props || {};
  389. return props.id !== itemProps.id && props.uri === itemProps.uri;
  390. })) {
  391. throw i18n('msgNamespaceConflict');
  392. }
  393. if (oldScript) {
  394. script.config = Object.assign({}, oldScript.config, config);
  395. script.props = Object.assign({}, oldScript.props, props);
  396. const index = store.scripts.indexOf(oldScript);
  397. store.scripts[index] = script;
  398. } else {
  399. if (!props.position) {
  400. store.storeInfo.position += 1;
  401. props.position = store.storeInfo.position;
  402. } else if (store.storeInfo.position < props.position) {
  403. store.storeInfo.position = props.position;
  404. }
  405. script.config = config;
  406. script.props = props;
  407. store.scripts.push(script);
  408. }
  409. return Promise.all([
  410. storage.script.dump(script),
  411. storage.code.set(props.id, code),
  412. ]);
  413. }
  414. export function updateScriptInfo(id, data) {
  415. const script = store.scriptMap[id];
  416. if (!script) return Promise.reject();
  417. script.props = Object.assign({}, script.props, data.props);
  418. script.config = Object.assign({}, script.config, data.config);
  419. // script.custom = Object.assign({}, script.custom, data.custom);
  420. return storage.script.dump(script);
  421. }
  422. export function getExportData(withValues) {
  423. return getScripts()
  424. .then(scripts => {
  425. const ids = scripts.map(({ props: { id } }) => id);
  426. return storage.code.getMulti(ids)
  427. .then(codeMap => {
  428. const data = {};
  429. data.items = scripts.map(script => ({ script, code: codeMap[script.props.id] }));
  430. if (withValues) {
  431. return storage.value.getMulti(ids)
  432. .then(values => {
  433. data.values = values;
  434. return data;
  435. });
  436. }
  437. return data;
  438. });
  439. });
  440. }
  441. const CMD_UPDATE = 'UpdateScript';
  442. const CMD_ADD = 'AddScript';
  443. export function parseScript(data) {
  444. const {
  445. id, code, message, isNew, config, custom, props, update,
  446. } = data;
  447. const meta = parseMeta(code);
  448. if (!meta.name) return Promise.reject(i18n('msgInvalidScript'));
  449. const result = {
  450. cmd: CMD_UPDATE,
  451. data: {
  452. update: {
  453. message: message == null ? i18n('msgUpdated') : message || '',
  454. },
  455. },
  456. };
  457. return getScript({ id, meta })
  458. .then(oldScript => {
  459. let script;
  460. if (oldScript) {
  461. if (isNew) throw i18n('msgNamespaceConflict');
  462. script = Object.assign({}, oldScript);
  463. } else {
  464. ({ script } = newScript());
  465. result.cmd = CMD_ADD;
  466. result.data.isNew = true;
  467. result.data.update.message = i18n('msgInstalled');
  468. }
  469. script.config = Object.assign({}, script.config, config, {
  470. removed: 0, // force reset `removed` since this is an installation
  471. });
  472. script.custom = Object.assign({}, script.custom, custom);
  473. script.props = Object.assign({}, script.props, {
  474. lastModified: Date.now(),
  475. lastUpdated: Date.now(),
  476. }, props);
  477. script.meta = meta;
  478. if (!meta.homepageURL && !script.custom.homepageURL && isRemote(data.from)) {
  479. script.custom.homepageURL = data.from;
  480. }
  481. if (isRemote(data.url)) script.custom.lastInstallURL = data.url;
  482. const position = +data.position;
  483. if (position) objectSet(script, 'props.position', position);
  484. buildPathMap(script, data.url);
  485. return saveScript(script, code).then(() => script);
  486. })
  487. .then(script => {
  488. fetchScriptResources(script, data);
  489. Object.assign(result.data.update, script, update);
  490. result.data.where = { id: script.props.id };
  491. sendMessageOrIgnore(result);
  492. pluginEvents.emit('scriptChanged', result.data);
  493. return result;
  494. });
  495. }
  496. function buildPathMap(script, base) {
  497. const { meta } = script;
  498. const baseUrl = base || script.custom.lastInstallURL;
  499. const pathMap = baseUrl ? [
  500. ...meta.require,
  501. ...Object.values(meta.resources),
  502. meta.icon,
  503. ].reduce((map, key) => {
  504. if (key) {
  505. const fullUrl = getFullUrl(key, baseUrl);
  506. if (fullUrl !== key) map[key] = fullUrl;
  507. }
  508. return map;
  509. }, {}) : {};
  510. script.custom.pathMap = pathMap;
  511. return pathMap;
  512. }
  513. function fetchScriptResources(script, cache) {
  514. const { meta, custom: { pathMap } } = script;
  515. // @require
  516. meta.require.forEach(key => {
  517. const fullUrl = pathMap[key] || key;
  518. const cached = objectGet(cache, ['require', fullUrl]);
  519. if (cached) {
  520. storage.require.set(fullUrl, cached);
  521. } else {
  522. storage.require.fetch(fullUrl);
  523. }
  524. });
  525. // @resource
  526. Object.values(meta.resources).forEach(url => {
  527. const fullUrl = pathMap[url] || url;
  528. const cached = objectGet(cache, ['resources', fullUrl]);
  529. if (cached) {
  530. storage.cache.set(fullUrl, cached);
  531. } else {
  532. storage.cache.fetch(fullUrl);
  533. }
  534. });
  535. // @icon
  536. if (isRemote(meta.icon)) {
  537. const fullUrl = pathMap[meta.icon] || meta.icon;
  538. storage.cache.fetch(fullUrl, ({ blob: getBlob }) => new Promise((resolve, reject) => {
  539. const blob = getBlob();
  540. const url = URL.createObjectURL(blob);
  541. const image = new Image();
  542. const free = () => URL.revokeObjectURL(url);
  543. image.onload = () => {
  544. free();
  545. resolve();
  546. };
  547. image.onerror = () => {
  548. free();
  549. reject({ type: 'IMAGE_ERROR', url });
  550. };
  551. image.src = url;
  552. }));
  553. }
  554. }
  555. export function vacuum() {
  556. const valueKeys = {};
  557. const cacheKeys = {};
  558. const requireKeys = {};
  559. const codeKeys = {};
  560. const mappings = [
  561. [storage.value, valueKeys],
  562. [storage.cache, cacheKeys],
  563. [storage.require, requireKeys],
  564. [storage.code, codeKeys],
  565. ];
  566. return browser.storage.local.get()
  567. .then(data => {
  568. Object.keys(data).forEach(key => {
  569. mappings.some(([substore, map]) => {
  570. const { prefix } = substore;
  571. if (key.startsWith(prefix)) {
  572. // -1 for untouched, 1 for touched, 2 for missing
  573. map[key.slice(prefix.length)] = -1;
  574. return true;
  575. }
  576. return false;
  577. });
  578. });
  579. const touch = (obj, key) => {
  580. if (obj[key] < 0) obj[key] = 1;
  581. else if (!obj[key]) obj[key] = 2;
  582. };
  583. store.scripts.forEach(script => {
  584. const { id } = script.props;
  585. touch(codeKeys, id);
  586. touch(valueKeys, id);
  587. if (!script.custom.pathMap) buildPathMap(script);
  588. const { pathMap } = script.custom;
  589. script.meta.require.forEach(url => {
  590. touch(requireKeys, pathMap[url] || url);
  591. });
  592. Object.values(script.meta.resources).forEach(url => {
  593. touch(cacheKeys, pathMap[url] || url);
  594. });
  595. const { icon } = script.meta;
  596. if (isRemote(icon)) {
  597. const fullUrl = pathMap[icon] || icon;
  598. touch(cacheKeys, fullUrl);
  599. }
  600. });
  601. mappings.forEach(([substore, map]) => {
  602. Object.keys(map).forEach(key => {
  603. const value = map[key];
  604. if (value < 0) {
  605. // redundant value
  606. substore.remove(key);
  607. } else if (value === 2 && substore.fetch) {
  608. // missing resource
  609. substore.fetch(key);
  610. }
  611. });
  612. });
  613. });
  614. }