diff --git a/api/src/routes/config.js b/api/src/routes/config.js index 3707c37d..52d76809 100644 --- a/api/src/routes/config.js +++ b/api/src/routes/config.js @@ -230,7 +230,7 @@ on('POST', '/api/config', async (req, res) => { }); on('GET', '/api/config/export', (_, res) => { - const safe = scrubSecrets(loadConfig()); + const safe = ensureSystemItems(scrubSecrets(loadConfig())); delete safe._rev; const d = JSON.stringify(safe, null, 2); res.writeHead(200, { diff --git a/api/test/http-integration.test.js b/api/test/http-integration.test.js index 4e7a4fe2..019add3e 100644 --- a/api/test/http-integration.test.js +++ b/api/test/http-integration.test.js @@ -35,7 +35,7 @@ const { unfinishedUpload } = require('../test-support/unfinished-upload'); require('../src/routes'); // registers auth/config/health/badges/... + OPTIONS require('../src/widget-data'); // registers /api/widget-data/:id (pulls in widgets) const { dispatch, on } = require('../src/router'); -const { saveConfig, loadConfig } = require('../src/config'); +const { saveConfig, loadConfig, loadConfigForUpdate } = require('../src/config'); const { hashPassword, makeToken, verifyToken, clearAttempts, RENEW_AFTER_MS } = require('../src/auth'); /* Routes that fail on purpose, to prove the router turns a handler error into a @@ -407,6 +407,19 @@ test('GET /api/config/export is downloadable and free of secrets', async () => { assert.equal(r.body.settings?.auth?.passwordHash, undefined); }); +test('GET /api/config/export lists the same items as GET /api/config', async () => { + const cfg = loadConfigForUpdate(); + cfg.items = []; + saveConfig(cfg, { keepRev: true }); + const exp = await req('GET', '/api/config/export', { cookie: validCookie }); + const live = await req('GET', '/api/config', { cookie: validCookie }); + assert.deepEqual( + exp.body.items.map(i => i.id), + live.body.items.map(i => i.id), + ); + assert.ok(exp.body.items.some(i => i.system === 'settings')); +}); + test('POST /api/config drops unknown settings and preserves the stored auth secret', async () => { const r = await req('POST', '/api/config', { cookie: validCookie, diff --git a/ui/admin/index.html b/ui/admin/index.html index 6228512d..3497c55b 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -301,6 +301,6 @@

General

- + diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index 3b98413c..666cbb63 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -11,7 +11,7 @@ import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; import { state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; +import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; import { qa } from '/js/utils.js?v=14b18b8c'; /** @type {(before: unknown) => void} */ diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 1e854410..c3de0fde 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -12,9 +12,9 @@ reads and writes them too. */ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; +import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=b2422767'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=d9f5ad98'; import { paintIcon } from '/js/admin-shared.js?v=36dc5cc1'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=14b18b8c'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; diff --git a/ui/js/admin-save-logic.js b/ui/js/admin-save-logic.js index f6e44662..048b4543 100644 --- a/ui/js/admin-save-logic.js +++ b/ui/js/admin-save-logic.js @@ -122,6 +122,25 @@ function sameData(a, b) { ); } +/** What restoring `incoming` over `current` does to the item list. Compare + against the server's export, not the editor's items: the editor keeps row + shapes and secrets the export does not. + @param {any[]} current @param {any[]} incoming + @returns {{ added: number, updated: number, deleted: number }} */ +export function importCounts(current, incoming) { + const cur = new Map(current.map(i => [i?.id, i])); + const inc = new Map(incoming.map(i => [i?.id, i])); + let added = 0, + updated = 0, + deleted = 0; + for (const [id, it] of inc) { + if (!cur.has(id)) added++; + else if (!sameData(cur.get(id), it)) updated++; + } + for (const id of cur.keys()) if (!inc.has(id)) deleted++; + return { added, updated, deleted }; +} + /** The settings a backup restores. The server owns `auth` and drops it from every write. @param {unknown} settings @returns {Record | null} */ diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 8e7f53b1..651d7bea 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -13,7 +13,7 @@ import { import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=14b18b8c'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { serialWrites } from '/js/admin-save-logic.js?v=5811620e'; +import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; import { renderColorControl } from '/js/admin-color-control.js?v=e80f4a68'; import { BACKDROP } from '/js/background.js?v=0f4ca726'; import { diff --git a/ui/js/admin.js b/ui/js/admin.js index 3eaf7152..aa0a7c9b 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,12 +1,13 @@ import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=dd5f148f'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=71f8a57a'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=5bdad01b'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=7b21e1a0'; import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; import { afterImport, buildAppItem, claimFolderChildren, + importCounts, newItemId, restoreBody, restoresSettings, @@ -14,14 +15,14 @@ import { serialWrites, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=5811620e'; +} from '/js/admin-save-logic.js?v=5613a9fa'; import { loadSettings, savedWallpaperUrl, settingsDirty, showBgFields, showWallpaperFile, -} from '/js/admin-settings.js?v=d69636fc'; +} from '/js/admin-settings.js?v=65546081'; import { apiGet, apiPost, @@ -55,7 +56,7 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=6ecd782f'; +} from '/js/import-foreign.js?v=3b635c16'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { @@ -1162,18 +1163,7 @@ el('imp').onchange = async e => { try { const d = JSON.parse(await f.text()); if (!d || !Array.isArray(d.items)) throw new ShownError(t('toast.importNotStackyard')); - const cur = new Map(state.items.map(i => [i.id, i])); - const inc = new Map(d.items.map(i => [i.id, i])); - let added = 0, - updated = 0, - deleted = 0; - for (const [id, it] of inc) { - if (!cur.has(id)) added++; - else if (JSON.stringify(cur.get(id)) !== JSON.stringify(it)) updated++; - } - for (const id of cur.keys()) { - if (!inc.has(id)) deleted++; - } + const { added, updated, deleted } = importCounts((await apiGet('/api/config/export')).items, d.items); const settingsToo = restoresSettings(d, await apiGet('/api/config')); if (added + updated + deleted === 0 && !settingsToo) { toast(t('toast.importNoChange')); diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js index 423cba87..8e9faecf 100644 --- a/ui/js/import-foreign.js +++ b/ui/js/import-foreign.js @@ -3,7 +3,7 @@ only: a widget in the source becomes a plain app tile, never a Stackyard widget. Keep this module free of the DOM and of the network. */ -import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=5811620e'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=5613a9fa'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ diff --git a/ui/test/admin-save-logic.test.mjs b/ui/test/admin-save-logic.test.mjs index c9bd87e2..9e6d7484 100644 --- a/ui/test/admin-save-logic.test.mjs +++ b/ui/test/admin-save-logic.test.mjs @@ -9,6 +9,7 @@ import { randomSuffix, snapshotItems, afterImport, + importCounts, restoreBody, restoresSettings, revertingSaves, @@ -638,3 +639,27 @@ test('a backup whose uploaded wallpaper is missing keeps the live background', ( assert.deepEqual(restoreBody(file, { settings: {} }, true).settings, { theme: 'dark' }); assert.equal(restoreBody(file, live).settings.background.url, '/icons/wallpaper/gone.jpg'); }); + +test('importCounts reports no change for the same items in another key order', () => { + const live = [ + { id: 'settings', type: 'app', system: 'settings' }, + { id: 'a', type: 'app', monitoring: { activity: { headers: [{ key: 'q', value: '1', secret: false }] } } }, + ]; + const file = [ + { system: 'settings', type: 'app', id: 'settings' }, + { type: 'app', id: 'a', monitoring: { activity: { headers: [{ secret: false, key: 'q', value: '1' }] } } }, + ]; + assert.deepEqual(importCounts(live, file), { added: 0, updated: 0, deleted: 0 }); +}); + +test('importCounts counts added, updated and deleted items', () => { + const live = [ + { id: 'a', type: 'app', label: 'A' }, + { id: 'b', type: 'app' }, + ]; + const file = [ + { id: 'a', type: 'app', label: 'A2' }, + { id: 'c', type: 'app' }, + ]; + assert.deepEqual(importCounts(live, file), { added: 1, updated: 1, deleted: 1 }); +});