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
36 changes: 24 additions & 12 deletions api/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,35 +211,28 @@ function _backupCorrupt(raw) {
return _lastBackup;
}

/* Returns the live cache. Do not write to it. Call loadConfigForUpdate to
change the config. A write here survives a failed save. */
function loadConfig() {
if (IS_DEMO) return loadDemoConfig();

const now = Date.now();
if (_cfgCache && now - _cfgCacheAt < CONFIG_TTL_MS) return _cfgCache;

function _readDisk() {
let raw;
try {
raw = fs.readFileSync(CONFIG_PATH, 'utf8');
} catch (e) {
if (e.code === 'ENOENT') _clearDamage();
else _markDamaged('unreadable', e.message);
return migrate({ items: [], settings: {} });
return null;
}

let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
_markDamaged('corrupt', e.message, _backupCorrupt(raw));
return migrate({ items: [], settings: {} });
return null;
}

const shaped = _normalizeShape(parsed);
if (!shaped) {
_markDamaged('corrupt', 'wrong shape', _backupCorrupt(raw));
return migrate({ items: [], settings: {} });
return null;
}

_clearDamage();
Expand All @@ -254,6 +247,20 @@ function loadConfig() {
...(backup ? { backup } : {}),
});
}
return shaped;
}

/* Returns the live cache. Do not write to it. Call loadConfigForUpdate to
change the config. A write here survives a failed save. */
function loadConfig() {
if (IS_DEMO) return loadDemoConfig();

const now = Date.now();
if (_cfgCache && now - _cfgCacheAt < CONFIG_TTL_MS) return _cfgCache;

const shaped = _readDisk();
if (!shaped) return migrate({ items: [], settings: {} });

const before = shaped._schemaVersion;
migrate(shaped);
_cfgCache = shaped;
Expand All @@ -269,14 +276,19 @@ function loadConfig() {
return shaped;
}

/* Reads the file, not the cache. A hand edit made in the cache window would
otherwise be overwritten by the next save. */
function loadConfigForUpdate() {
_cfgCacheAt = 0;
return structuredClone(loadConfig());
}

/* keepRev is only for writes the browser never sees. Every open dashboard
reloads when _rev changes. */
function saveConfig(data, { keepRev = false } = {}) {
if (_damage) loadConfig();
/* Check the file, not the cache. A file broken after the last read must be
kept, not overwritten. */
_readDisk();
if (_damage) throw new Error('config file cannot be used; refusing to overwrite it');
if (data && typeof data === 'object') {
data._schemaVersion = SCHEMA_VERSION;
Expand Down
2 changes: 1 addition & 1 deletion api/src/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ on('POST', '/api/config', async (req, res) => {
if ('keepAwake' in data.settings) data.settings.keepAwake = data.settings.keepAwake === true;
if ('typeToSearch' in data.settings) data.settings.typeToSearch = data.settings.typeToSearch !== false;
}
const existing = loadConfig();
const existing = loadConfigForUpdate();
/* Stale-write check. A client that sends no _rev overwrites. */
if (data._rev != null && Number(data._rev) !== (Number(existing._rev) || 0))
return json(res, 409, {
Expand Down
28 changes: 28 additions & 0 deletions api/test/config-corrupt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,31 @@ test('item entries that are not objects are skipped and the file is backed up',
assert.equal(b.length, 1);
assert.equal(fs.readFileSync(path.join(DIR, b[0]), 'utf8'), raw);
});

test('a file broken after the last read is kept, not overwritten by the next save', () => {
reset();
const cfg = fresh();
cfg.saveConfig({ items: [{ id: 'a', type: 'app' }], settings: {} });
cfg.loadConfig();
fs.writeFileSync(CFG, '{ edited by hand');
assert.throws(() => cfg.saveConfig(cfg.loadConfigForUpdate()));
assert.equal(fs.readFileSync(CFG, 'utf8'), '{ edited by hand');
assert.equal(backups().length, 1);
});

test('a hand edit made after the last read survives a read-modify-write', () => {
reset();
const cfg = fresh();
cfg.saveConfig({ items: [], settings: { theme: 'dark' } });
cfg.loadConfig();
fs.writeFileSync(
CFG,
JSON.stringify({ _schemaVersion: cfg.SCHEMA_VERSION, items: [], settings: { theme: 'light' } }),
);
const next = cfg.loadConfigForUpdate();
next.settings.language = 'de';
cfg.saveConfig(next);
const onDisk = JSON.parse(fs.readFileSync(CFG, 'utf8'));
assert.equal(onDisk.settings.theme, 'light');
assert.equal(onDisk.settings.language, 'de');
});
Loading