Files
webmail/lib/stored-data-quarantine.ts
T
Linus Rath 6d22814e5f fix: move settings and plugin credentials that no longer decrypt aside instead of refusing every save #1066
The earlier fix answered 503 to every read and write of a settings file
or plugin vault that doesn't decrypt, so a rotated SESSION_SECRET left
the user with no way out: even disconnecting a plugin failed. Now such a
document reads as nothing stored, and the next write first moves it
aside, then saves as usual. The bytes are kept unchanged, so the data can
be restored with the old secret:

- local mode renames the file to `<file>.unreadable-<time>` next to it
- shared mode copies the document to `<id>.unreadable-<time>`, then
  deletes the original only if it is still the same document

A settings file too short to hold anything reads as nothing stored and
is written over, as before 585e10041. The 503 is now only for a shared
store that can't be reached or a file that can't be read at all.

Credentials the admin set up for a plugin no longer read the user's
vault, so they keep working when it can't be read, and the credential
list still reports their status.
2026-10-31 23:57:08 +01:00

51 lines
2.0 KiB
TypeScript

import { readFile, rename } from 'node:fs/promises';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { isSharedMode } from '@/lib/shared-store';
import { versionOf } from '@/lib/shared-store/keys';
import { serializeLocal } from '@/lib/shared-store/local';
import type { DocCollection, StoredDoc } from '@/lib/shared-store/types';
/**
* Put a user's document that no longer decrypts out of the way, so the
* user can save again, without losing its bytes (#1066). It most likely was
* written with another session secret (rotated, or a replica that had its
* own); with that secret back it can be restored by hand.
*
* - Local mode: renamed to `<file>.unreadable-<time>` next to it.
* - Shared mode: copied to `<id>.unreadable-<time>`, then the original is
* deleted, only if it is still the same document.
*
* Either way a concurrent write that replaced it meanwhile is left alone.
*/
export async function moveUnreadableAside(
docs: DocCollection,
id: string,
current: StoredDoc,
localFile: string,
what: string,
): Promise<void> {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
if (!isSharedMode()) {
const file = path.resolve(localFile);
const target = `${file}.unreadable-${stamp}`;
// On the collection's own queue for this file, so no write lands between
// the check and the rename.
const moved = await serializeLocal(`doc\0${file}`, async () => {
const now = await readFile(file).catch(() => null);
if (!now || versionOf(now) !== current.version) return false;
await rename(file, target);
return true;
});
if (moved) logger.warn(`Moved aside stored ${what} that could not be decrypted`, { path: target });
return;
}
const aside = `${id}.unreadable-${stamp}`;
await docs.put(aside, current.data, { ifVersion: null });
if (await docs.delete(id, { ifVersion: current.version })) {
logger.warn(`Moved aside stored ${what} that could not be decrypted`, { collection: docs.name, id: aside });
} else {
await docs.delete(aside).catch(() => {});
}
}