Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/src/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
15 changes: 14 additions & 1 deletion api/test/http-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,6 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div></div>
</div>

<script type="module" src="/js/admin.js?v=74038701"></script>
<script type="module" src="/js/admin.js?v=6a405722"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion ui/js/admin-drag.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand Down
4 changes: 2 additions & 2 deletions ui/js/admin-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
19 changes: 19 additions & 0 deletions ui/js/admin-save-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null} */
Expand Down
2 changes: 1 addition & 1 deletion ui/js/admin-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 6 additions & 16 deletions ui/js/admin.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
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,
revertingSaves,
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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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'));
Expand Down
2 changes: 1 addition & 1 deletion ui/js/import-foreign.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
25 changes: 25 additions & 0 deletions ui/test/admin-save-logic.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
randomSuffix,
snapshotItems,
afterImport,
importCounts,
restoreBody,
restoresSettings,
revertingSaves,
Expand Down Expand Up @@ -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 });
});
Loading