mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -120,13 +120,20 @@ describe('settings with a shared store', () => {
|
||||
expect((await (await fetchSettings()).json()).settings).toBeNull();
|
||||
});
|
||||
|
||||
it('answers 503 to a save over a stored document that does not decrypt and leaves its bytes alone', async () => {
|
||||
it('moves a stored document that does not decrypt aside in the store before a save, keeping its bytes', async () => {
|
||||
const garbage = Buffer.alloc(64, 7);
|
||||
await store.collection('settings', SETTINGS_COLLECTION).put(settingsId(USER, SERVER), garbage);
|
||||
const id = settingsId(USER, SERVER);
|
||||
await store.collection('settings', SETTINGS_COLLECTION).put(id, garbage);
|
||||
|
||||
expect((await push({ theme: 'light' })).status).toBe(503);
|
||||
expect((await fetchSettings()).status).toBe(503);
|
||||
expect((await storedDoc())?.data.equals(garbage)).toBe(true);
|
||||
expect((await (await fetchSettings()).json()).settings).toBeNull();
|
||||
expect((await push({ theme: 'light' })).status).toBe(200);
|
||||
expect((await (await fetchSettings()).json()).settings).toEqual({ theme: 'light' });
|
||||
|
||||
// In the store, not on this replica's disk.
|
||||
const aside = readdirSync(path.join(store.root, 'settings')).filter((f) => f.startsWith(`${id}.unreadable-`));
|
||||
expect(aside).toHaveLength(1);
|
||||
const asideId = aside[0].slice(0, -'.enc'.length);
|
||||
expect((await store.collection('settings', SETTINGS_COLLECTION).get(asideId))?.data.equals(garbage)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers 503 and writes nothing while the store is down', async () => {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// A settings file or plugin vault that exists but doesn't decrypt (written
|
||||
// with another session secret, or cut short) used to read as "nothing
|
||||
// stored". The next save then merged into nothing and overwrote the user's
|
||||
// real data. Now the read throws, and the routes answer 503 and write nothing.
|
||||
// with another session secret) used to read as "nothing stored", and the
|
||||
// next save wrote over it: the user's real data was gone for good. Now the
|
||||
// save first moves it aside (`<file>.unreadable-<time>`, bytes unchanged)
|
||||
// and goes on as if nothing was stored, so the user can save again and the
|
||||
// old data can still be restored with the old secret.
|
||||
|
||||
let secret = 'a'.repeat(64);
|
||||
vi.mock('@/lib/auth/session-secret', () => ({
|
||||
@@ -26,6 +28,13 @@ vi.mock('@/lib/admin/config-manager', () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => false, thirdPartyRefusal: () => null }));
|
||||
// One installed plugin with a credential the admin set up.
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPlugin: async (id: string) => (id === 'paperless'
|
||||
? { id, enabled: true, credentials: { shared: { scope: 'admin', label: 'Paperless', inject: { type: 'bearer' }, origin: 'https://paperless.example.com', secretKey: 'token' } } }
|
||||
: null),
|
||||
}));
|
||||
vi.mock('@/lib/admin/plugin-config', () => ({ getPluginConfig: async () => ({ token: 'admin-token' }) }));
|
||||
|
||||
const jar = new Map<string, string>();
|
||||
vi.mock('next/headers', () => ({
|
||||
@@ -72,29 +81,38 @@ async function signIn() {
|
||||
} as never, 0, { serverUrl: SERVER, authHeader: 'Bearer t', username: USER });
|
||||
}
|
||||
|
||||
function files(dir: string): string[] {
|
||||
return readdirSync(dir).sort();
|
||||
}
|
||||
|
||||
function onlyFile(dir: string): string {
|
||||
const [file] = readdirSync(dir).filter((name) => name.endsWith('.enc'));
|
||||
const [file] = files(dir).filter((name) => name.endsWith('.enc'));
|
||||
return path.join(dir, file);
|
||||
}
|
||||
|
||||
function asideOf(dir: string, file: string): string | undefined {
|
||||
const name = files(dir).find((n) => n.startsWith(`${path.basename(file)}.unreadable-`));
|
||||
return name && path.join(dir, name);
|
||||
}
|
||||
|
||||
describe('settings that do not decrypt', () => {
|
||||
it('reads a missing file as nothing stored and one written with another secret as an error', async () => {
|
||||
it('read as nothing stored, and a file too short to hold any as well', async () => {
|
||||
const { loadUserSettings, saveUserSettings } = await import('@/lib/settings-sync');
|
||||
const { StoredDataUnreadableError } = await import('@/lib/stored-data-errors');
|
||||
expect(await loadUserSettings(USER, SERVER)).toBeNull();
|
||||
|
||||
await saveUserSettings(USER, SERVER, { theme: 'dark' });
|
||||
secret = 'b'.repeat(64);
|
||||
await expect(loadUserSettings(USER, SERVER)).rejects.toBeInstanceOf(StoredDataUnreadableError);
|
||||
expect(await loadUserSettings(USER, SERVER)).toBeNull();
|
||||
|
||||
writeFileSync(onlyFile(path.join(dataDir, 'settings')), Buffer.alloc(10));
|
||||
await expect(loadUserSettings(USER, SERVER)).rejects.toBeInstanceOf(StoredDataUnreadableError);
|
||||
expect(await loadUserSettings(USER, SERVER)).toBeNull();
|
||||
});
|
||||
|
||||
it('answers 503 to GET and POST and leaves the stored file as it was', async () => {
|
||||
const { saveUserSettings } = await import('@/lib/settings-sync');
|
||||
it('are moved aside unchanged by the next save, which then goes through', async () => {
|
||||
const { saveUserSettings, loadUserSettings } = await import('@/lib/settings-sync');
|
||||
await saveUserSettings(USER, SERVER, { theme: 'dark', templates: [{ id: 'a' }] });
|
||||
const file = onlyFile(path.join(dataDir, 'settings'));
|
||||
const dir = path.join(dataDir, 'settings');
|
||||
const file = onlyFile(dir);
|
||||
const before = readFileSync(file);
|
||||
secret = 'b'.repeat(64);
|
||||
await signIn();
|
||||
@@ -103,15 +121,34 @@ describe('settings that do not decrypt', () => {
|
||||
const got = await GET(new NextRequest('https://webmail.example/api/settings', {
|
||||
headers: { 'x-settings-username': USER, 'x-settings-server': SERVER },
|
||||
}));
|
||||
expect(got.status).toBe(503);
|
||||
expect(got.status).toBe(200);
|
||||
expect((await got.json()).settings).toBeNull();
|
||||
|
||||
const saved = await POST(new NextRequest('https://webmail.example/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username: USER, serverUrl: SERVER, settings: { theme: 'light' } }),
|
||||
}));
|
||||
expect(saved.status).toBe(503);
|
||||
expect(readFileSync(file).equals(before)).toBe(true);
|
||||
expect(saved.status).toBe(200);
|
||||
expect(await loadUserSettings(USER, SERVER)).toEqual({ theme: 'light' });
|
||||
|
||||
const aside = asideOf(dir, file);
|
||||
expect(aside).toBeDefined();
|
||||
expect(readFileSync(aside!).equals(before)).toBe(true);
|
||||
// With the old secret back, the moved file still holds the old settings.
|
||||
secret = 'a'.repeat(64);
|
||||
writeFileSync(file, readFileSync(aside!));
|
||||
expect(await loadUserSettings(USER, SERVER)).toEqual({ theme: 'dark', templates: [{ id: 'a' }] });
|
||||
});
|
||||
|
||||
it('a file too short to hold any is written over, not moved', async () => {
|
||||
const { updateUserSettings, loadUserSettings, settingsId } = await import('@/lib/settings-sync');
|
||||
const dir = path.join(dataDir, 'settings');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path.join(dir, `${settingsId(USER, SERVER)}.enc`), Buffer.alloc(0));
|
||||
await updateUserSettings(USER, SERVER, (stored) => ({ ...(stored ?? {}), theme: 'dark' }));
|
||||
expect(files(dir)).toEqual([`${settingsId(USER, SERVER)}.enc`]);
|
||||
expect(await loadUserSettings(USER, SERVER)).toEqual({ theme: 'dark' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,44 +156,57 @@ describe('a plugin vault that does not decrypt', () => {
|
||||
const user = { username: USER, serverUrl: SERVER };
|
||||
const input = { origin: 'https://cloud.example.org', inject: { type: 'basic' } as const, label: 'Nextcloud', secret: 'pw' };
|
||||
|
||||
it('reads a missing vault as empty and one written with another secret as an error', async () => {
|
||||
it('reads as empty, and the next write moves it aside unchanged and goes through', async () => {
|
||||
const { listUserCredentials, putUserCredential } = await import('@/lib/plugin-secrets/vault');
|
||||
const { StoredDataUnreadableError } = await import('@/lib/stored-data-errors');
|
||||
expect(await listUserCredentials(user)).toEqual([]);
|
||||
|
||||
await putUserCredential(user, 'nextcloud', 'cloud', input);
|
||||
const dir = path.join(dataDir, 'plugin-secrets');
|
||||
const file = onlyFile(dir);
|
||||
const before = readFileSync(file);
|
||||
secret = 'b'.repeat(64);
|
||||
await expect(listUserCredentials(user)).rejects.toBeInstanceOf(StoredDataUnreadableError);
|
||||
// A write merges into what is stored, so it must not land either.
|
||||
await expect(putUserCredential(user, 'other', 'x', input)).rejects.toBeInstanceOf(StoredDataUnreadableError);
|
||||
|
||||
expect(await listUserCredentials(user)).toEqual([]);
|
||||
await putUserCredential(user, 'other', 'x', input);
|
||||
expect((await listUserCredentials(user)).map((c) => c.pluginId)).toEqual(['other']);
|
||||
const aside = asideOf(dir, file);
|
||||
expect(aside).toBeDefined();
|
||||
expect(readFileSync(aside!).equals(before)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers 503 on the credential routes and the proxy and leaves the vault as it was', async () => {
|
||||
it('lets the routes work again: list, connect, disconnect, and the proxy finds no credential', async () => {
|
||||
const { putUserCredential } = await import('@/lib/plugin-secrets/vault');
|
||||
await putUserCredential(user, 'nextcloud', 'cloud', input);
|
||||
const file = onlyFile(path.join(dataDir, 'plugin-secrets'));
|
||||
const before = readFileSync(file);
|
||||
secret = 'b'.repeat(64);
|
||||
|
||||
const { GET, POST, DELETE } = await import('@/app/api/plugin-secrets/route');
|
||||
const { POST: proxy } = await import('@/app/api/plugin-proxy/route');
|
||||
const same = { 'sec-fetch-site': 'same-origin' };
|
||||
|
||||
expect((await GET(new NextRequest('http://localhost/api/plugin-secrets'))).status).toBe(503);
|
||||
const post = await POST(new NextRequest('http://localhost/api/plugin-secrets', {
|
||||
method: 'POST',
|
||||
headers: same,
|
||||
body: JSON.stringify({ pluginId: 'nextcloud', credentialId: 'other', label: 'x', origin: 'https://cloud.example.org', inject: { type: 'basic' }, secret: 's' }),
|
||||
}));
|
||||
expect(post.status).toBe(503);
|
||||
const del = await DELETE(new NextRequest('http://localhost/api/plugin-secrets?plugin=nextcloud', { method: 'DELETE', headers: same }));
|
||||
expect(del.status).toBe(503);
|
||||
const listed = await GET(new NextRequest('http://localhost/api/plugin-secrets?plugin=paperless'));
|
||||
expect(listed.status).toBe(200);
|
||||
expect(await listed.json()).toMatchObject({ credentials: [], admin: [{ credentialId: 'shared', configured: true }] });
|
||||
const proxied = await proxy(new NextRequest(
|
||||
'http://localhost/api/plugin-proxy?plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2F',
|
||||
{ method: 'POST', headers: same },
|
||||
));
|
||||
expect(proxied.status).toBe(503);
|
||||
expect(proxied.status).toBe(404);
|
||||
const post = await POST(new NextRequest('http://localhost/api/plugin-secrets', {
|
||||
method: 'POST',
|
||||
headers: same,
|
||||
body: JSON.stringify({ pluginId: 'nextcloud', credentialId: 'cloud', label: 'x', origin: 'https://cloud.example.org', inject: { type: 'basic' }, secret: 's' }),
|
||||
}));
|
||||
expect(post.status).toBe(200);
|
||||
const del = await DELETE(new NextRequest('http://localhost/api/plugin-secrets?plugin=nextcloud', { method: 'DELETE', headers: same }));
|
||||
expect(del.status).toBe(200);
|
||||
});
|
||||
|
||||
expect(readFileSync(file).equals(before)).toBe(true);
|
||||
it('resolves an admin credential without reading the user vault', async () => {
|
||||
const { resolveCredential } = await import('@/lib/plugin-secrets/proxy');
|
||||
// A vault that can't even be read (a folder where the file should be).
|
||||
const { userKey } = await import('@/lib/plugin-secrets/vault');
|
||||
mkdirSync(path.join(dataDir, 'plugin-secrets', `${userKey(user)}.enc`), { recursive: true });
|
||||
|
||||
expect(await resolveCredential(user, 'paperless', 'shared')).toMatchObject({ scope: 'admin', secret: 'admin-token' });
|
||||
await expect(resolveCredential(user, 'nextcloud', 'cloud')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +69,12 @@ export interface ResolvedCredential {
|
||||
* admin configured for an installed plugin that declares it.
|
||||
*/
|
||||
export async function resolveCredential(user: VaultUser, pluginId: string, credentialId: string): Promise<ResolvedCredential | null> {
|
||||
const stored = await getUserCredential(user, pluginId, credentialId);
|
||||
const plugin = await getPlugin(pluginId);
|
||||
const spec = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials?.[credentialId];
|
||||
// A credential the admin set up never depends on the user's own vault: a
|
||||
// user can only store credentials a plugin declares for users, so there
|
||||
// is nothing of theirs to find under this id.
|
||||
const stored = spec?.scope === 'admin' ? null : await getUserCredential(user, pluginId, credentialId);
|
||||
if (stored) {
|
||||
// Only for a plugin this server knows declares it, on the server the
|
||||
// manifest fixes (see declaredUserCredential).
|
||||
@@ -81,8 +86,6 @@ export async function resolveCredential(user: VaultUser, pluginId: string, crede
|
||||
trusted: privateProxyOrigins().includes(stored.origin), scope: 'user',
|
||||
};
|
||||
}
|
||||
const plugin = await getPlugin(pluginId);
|
||||
const spec = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials?.[credentialId];
|
||||
if (!plugin || !plugin.enabled || !spec || spec.scope !== 'admin') return null;
|
||||
const config = await getPluginConfig(pluginId);
|
||||
const secret = config[spec.secretKey];
|
||||
|
||||
+34
-13
@@ -7,7 +7,8 @@ import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
|
||||
import { getStore, type CollectionOptions } from '@/lib/shared-store';
|
||||
import { isStoreUnavailable, StoredDataUnreadableError } from '@/lib/stored-data-errors';
|
||||
import { isStoreUnavailable, isStoredDataUnreadable, StoredDataUnreadableError } from '@/lib/stored-data-errors';
|
||||
import { moveUnreadableAside } from '@/lib/stored-data-quarantine';
|
||||
import type { CredentialInject } from './credentials';
|
||||
|
||||
export interface VaultUser {
|
||||
@@ -72,7 +73,7 @@ function collection() {
|
||||
/**
|
||||
* The user's credentials from a stored document; {} for none. One that
|
||||
* doesn't decrypt (another session secret) throws StoredDataUnreadableError,
|
||||
* so no write lands on top of it.
|
||||
* so no write lands on top of it before it is moved aside (see modify).
|
||||
*/
|
||||
function decode(user: VaultUser, raw: Buffer | null): VaultData {
|
||||
if (!raw) return {};
|
||||
@@ -93,7 +94,13 @@ async function load(user: VaultUser): Promise<VaultData> {
|
||||
if (isStoreUnavailable(error)) throw error;
|
||||
throw new StoredDataUnreadableError('plugin credentials', { cause: error });
|
||||
}
|
||||
return decode(user, stored?.data ?? null);
|
||||
try {
|
||||
return decode(user, stored?.data ?? null);
|
||||
} catch (error) {
|
||||
// Reads as no credentials; the next write moves it aside.
|
||||
if (isStoredDataUnreadable(error)) return {};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,18 +108,32 @@ async function load(user: VaultUser): Promise<VaultData> {
|
||||
* two writes at once (two tabs, two replicas) both land. `change` edits the
|
||||
* data it gets and says whether it changed anything; it may run more than
|
||||
* once. An empty vault is deleted.
|
||||
*
|
||||
* A vault that doesn't decrypt is moved aside first (kept next to the
|
||||
* original as `.unreadable-<time>`, never overwritten), and the write goes
|
||||
* on as if nothing was stored.
|
||||
*/
|
||||
async function modify<T>(user: VaultUser, change: (data: VaultData) => { result: T; changed: boolean }): Promise<T> {
|
||||
let result!: T;
|
||||
await collection().update(userKey(user), (current) => {
|
||||
const data = decode(user, current);
|
||||
const out = change(data);
|
||||
result = out.result;
|
||||
if (!out.changed) return undefined;
|
||||
if (Object.keys(data).length === 0) return current ? null : undefined;
|
||||
return encryptPayload({ u: userKey(user), d: data }, 'plugin-secrets');
|
||||
});
|
||||
return result;
|
||||
const docs = collection();
|
||||
const id = userKey(user);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
let result!: T;
|
||||
await docs.update(id, (current) => {
|
||||
const data = decode(user, current);
|
||||
const out = change(data);
|
||||
result = out.result;
|
||||
if (!out.changed) return undefined;
|
||||
if (Object.keys(data).length === 0) return current ? null : undefined;
|
||||
return encryptPayload({ u: userKey(user), d: data }, 'plugin-secrets');
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!isStoredDataUnreadable(error) || attempt >= 2) throw error;
|
||||
const current = await docs.get(id);
|
||||
if (current) await moveUnreadableAside(docs, id, current, path.join(vaultDir(), `${id}.enc`), 'plugin credentials');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserCredential(user: VaultUser, pluginId: string, credentialId: string): Promise<StoredCredential | null> {
|
||||
|
||||
+38
-12
@@ -3,7 +3,8 @@ import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { getStore, type CollectionOptions } from '@/lib/shared-store';
|
||||
import { isStoreUnavailable, StoredDataUnreadableError } from '@/lib/stored-data-errors';
|
||||
import { isStoreUnavailable, isStoredDataUnreadable, StoredDataUnreadableError } from '@/lib/stored-data-errors';
|
||||
import { moveUnreadableAside } from '@/lib/stored-data-quarantine';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
@@ -52,8 +53,13 @@ function encryptSettings(settings: Settings): Buffer {
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
|
||||
}
|
||||
|
||||
function decryptSettings(data: Buffer): Settings {
|
||||
if (data.length < IV_LENGTH + TAG_LENGTH) throw new StoredDataUnreadableError('settings');
|
||||
/**
|
||||
* Settings from stored bytes. Too short to hold anything reads as nothing
|
||||
* stored, as it always has; bytes that don't decrypt throw
|
||||
* StoredDataUnreadableError.
|
||||
*/
|
||||
function decryptSettings(data: Buffer): Settings | null {
|
||||
if (data.length < IV_LENGTH + TAG_LENGTH) return null;
|
||||
const key = getKey();
|
||||
try {
|
||||
const iv = data.subarray(0, IV_LENGTH);
|
||||
@@ -76,11 +82,11 @@ export async function saveUserSettings(username: string, serverUrl: string, sett
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's stored settings, or null when none are stored. A document that
|
||||
* exists but can't be read or decrypted (another session secret, cut short)
|
||||
* throws StoredDataUnreadableError: treating it as "nothing stored" would
|
||||
* let the next save overwrite the user's real settings. A shared store that
|
||||
* can't be reached throws StoreUnavailableError.
|
||||
* The user's stored settings, or null when none are stored. Settings that
|
||||
* don't decrypt (written with another session secret) read as none too; the
|
||||
* next save moves them aside before it writes (see updateUserSettings). A
|
||||
* file that can't be read throws StoredDataUnreadableError, a shared store
|
||||
* that can't be reached StoreUnavailableError.
|
||||
*/
|
||||
export async function loadUserSettings(username: string, serverUrl: string): Promise<Settings | null> {
|
||||
let stored;
|
||||
@@ -91,22 +97,42 @@ export async function loadUserSettings(username: string, serverUrl: string): Pro
|
||||
logger.warn('Failed to read user settings', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
throw new StoredDataUnreadableError('settings', { cause: error });
|
||||
}
|
||||
return stored ? decryptSettings(stored.data) : null;
|
||||
if (!stored) return null;
|
||||
try {
|
||||
return decryptSettings(stored.data);
|
||||
} catch (error) {
|
||||
if (isStoredDataUnreadable(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read, change and write the user's settings in one step, so a save that
|
||||
* lands at the same time (another tab, another replica) isn't lost: `change`
|
||||
* gets what is stored now (null for nothing) and returns what to store. It
|
||||
* may run more than once. Throws like loadUserSettings, writing nothing.
|
||||
* may run more than once.
|
||||
*
|
||||
* Stored settings that don't decrypt are moved aside first (kept next to
|
||||
* the original as `.unreadable-<time>`, never overwritten), and the save
|
||||
* goes on as if nothing was stored.
|
||||
*/
|
||||
export async function updateUserSettings(
|
||||
username: string,
|
||||
serverUrl: string,
|
||||
change: (stored: Settings | null) => Settings,
|
||||
): Promise<void> {
|
||||
await collection().update(settingsId(username, serverUrl), (current) =>
|
||||
encryptSettings(change(current ? decryptSettings(current) : null)));
|
||||
const docs = collection();
|
||||
const id = settingsId(username, serverUrl);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await docs.update(id, (current) => encryptSettings(change(current ? decryptSettings(current) : null)));
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isStoredDataUnreadable(error) || attempt >= 2) throw error;
|
||||
const current = await docs.get(id);
|
||||
if (current) await moveUnreadableAside(docs, id, current, path.join(getSettingsDir(), `${id}.enc`), 'settings');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUserSettings(username: string, serverUrl: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user