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
8 changes: 7 additions & 1 deletion backend/src/backups/backup-storage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('Backup trust boundaries', () => {
).toThrow('verification');
expect(() =>
validateSettings({
provider: 'ftp' as never,
provider: 'ftp',
enabled: false,
intervalMinutes: 60,
}),
Expand Down Expand Up @@ -79,3 +79,9 @@ it('rejects non-string manifest dates before status sorting', () => {
'verification',
);
});
it.each([null, [], 'not settings'])(
'rejects malformed stored settings at the boundary: %p',
(value) => {
expect(() => validateSettings(value)).toThrow('Invalid backup settings');
},
);
41 changes: 38 additions & 3 deletions backend/src/backups/backup-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@ export interface BackupSettings {
hostKey?: string;
authentication?: 'privateKey' | 'password';
}
export type LocalBackupSettings = BackupSettings & {
provider: 'local';
directory: string;
};
export type S3BackupSettings = BackupSettings & {
provider: 's3';
bucket: string;
region: string;
endpoint: string;
prefix: string;
forcePathStyle: boolean;
};
export type SftpBackupSettings = BackupSettings & {
provider: 'sftp';
host: string;
port: number;
username: string;
remoteDirectory: string;
hostKey: string;
authentication: 'privateKey' | 'password';
};
export type ValidatedBackupSettings =
LocalBackupSettings | S3BackupSettings | SftpBackupSettings;
export interface BackupSecrets {
accessKeyId?: string;
secretAccessKey?: string;
Expand Down Expand Up @@ -77,7 +100,10 @@ export function validateSnapshot(value: unknown, id: string): Snapshot {
);
return s;
}
export function validateSettings(input: BackupSettings): BackupSettings {
export function validateSettings(value: unknown): ValidatedBackupSettings {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new BadRequestException('Invalid backup settings.');
const input = value as BackupSettings;
const provider = input.provider ?? 'local';
if (
!['local', 's3', 'sftp'].includes(provider) ||
Expand All @@ -103,7 +129,11 @@ export function validateSettings(input: BackupSettings): BackupSettings {
return value;
};
if (provider === 'local')
return { ...shared, directory: text(input.directory, 'directory') };
return {
...shared,
provider: 'local',
directory: text(input.directory, 'directory'),
};
if (provider === 's3') {
const endpoint = input.endpoint || '';
if (endpoint) {
Expand Down Expand Up @@ -144,13 +174,17 @@ export function validateSettings(input: BackupSettings): BackupSettings {
throw new BadRequestException('Invalid S3 bucket.');
return {
...shared,
provider: 's3',
bucket,
region: text(input.region || 'us-east-1', 'region'),
endpoint,
prefix,
forcePathStyle: input.forcePathStyle ?? false,
};
}
const authentication = input.authentication;
if (authentication !== 'password' && authentication !== 'privateKey')
throw new BadRequestException('Invalid SFTP authentication.');
const host = text(input.host, 'host');
const username = text(input.username, 'username');
const remoteDirectory = text(input.remoteDirectory, 'remote directory');
Expand All @@ -175,11 +209,12 @@ export function validateSettings(input: BackupSettings): BackupSettings {
);
return {
...shared,
provider: 'sftp',
host,
username,
remoteDirectory: remoteDirectory.replace(/\/+$/, ''),
hostKey,
port: input.port ?? 22,
authentication: input.authentication,
authentication,
};
}
15 changes: 13 additions & 2 deletions backend/src/backups/backups.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,10 @@ describe('Remote backup settings', () => {
storageProvider: 'local',
uploadRoot: join(root, 'uploads'),
});
const backup = new BackupsService(config as never, prisma as never);
const backup = await resolveService(BackupsService, [
{ provide: ConfigService, useValue: config },
{ provide: PrismaService, useValue: prisma },
]);
try {
const input = {
provider: 's3' as const,
Expand Down Expand Up @@ -209,6 +212,11 @@ describe('Remote backup settings', () => {
expect(capture).not.toHaveBeenCalled();
await backup.runScheduled(new Date('2026-10-12T01:00:00Z'));
expect(capture).toHaveBeenCalledWith('scheduled');
value = { ...status.settings, credentials: null };
expect((await backup.status()).reason).toBe('STORAGE_UNAVAILABLE');
await expect(backup.configure(input)).resolves.toMatchObject({
configured: true,
});
list.mockRejectedValue(new Error('private error'));
expect((await backup.status()).reason).toBe('STORAGE_UNAVAILABLE');
} finally {
Expand All @@ -229,7 +237,10 @@ it('preserves a truncated marker when the first upload rename fails', async () =
storageProvider: 'local',
uploadRoot: uploads,
});
const backup = new BackupsService(config as never, {} as PrismaService);
const backup = await resolveService(BackupsService, [
{ provide: ConfigService, useValue: config },
{ provide: PrismaService, useValue: {} },
]);
// A vanished live upload directory reproduces the first-rename failure without touching a database.
const internals = backup as unknown as {
verifySnapshot: () => Promise<void>;
Expand Down
51 changes: 40 additions & 11 deletions backend/src/backups/backups.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
BACKUP_UUID as UUID,
validateSettings,
type BackupSettings,
type ValidatedBackupSettings,
type BackupStorage,
type BackupSecrets,
type Snapshot,
Expand All @@ -43,9 +44,9 @@ import {
import { LocalBackupStorage } from './local-backup-storage';
import { S3BackupStorage } from './s3-backup-storage';
import { SftpBackupStorage } from './sftp-backup-storage';
interface StoredSettings extends BackupSettings {
type StoredSettings = ValidatedBackupSettings & {
credentials?: EncryptedSecrets;
}
};

const execute = promisify(execFile);
const KEY = 'backups.local';
Expand Down Expand Up @@ -111,7 +112,35 @@ export class BackupsService implements OnModuleInit, OnModuleDestroy {
const row = await this.prisma.siteSetting.findUnique({
where: { key: KEY },
});
return row ? (row.value as unknown as StoredSettings) : null;
if (!row) return null;
const settings = validateSettings(row.value);
if (
row.value &&
typeof row.value === 'object' &&
!Array.isArray(row.value) &&
'credentials' in row.value &&
row.value.credentials !== undefined
) {
const encrypted = row.value.credentials;
if (
!encrypted ||
typeof encrypted !== 'object' ||
Array.isArray(encrypted) ||
typeof encrypted.iv !== 'string' ||
typeof encrypted.tag !== 'string' ||
typeof encrypted.ciphertext !== 'string'
)
return settings; // Missing credentials can be repaired by re-entering them.
return {
...settings,
credentials: {
iv: encrypted.iv,
tag: encrypted.tag,
ciphertext: encrypted.ciphertext,
},
};
}
return settings;
}

private credentials() {
Expand All @@ -124,8 +153,8 @@ export class BackupsService implements OnModuleInit, OnModuleDestroy {
settings: StoredSettings,
explicitSecrets?: BackupSecrets,
): Promise<BackupStorage> {
if (!settings.provider || settings.provider === 'local')
return new LocalBackupStorage(settings.directory!);
if (settings.provider === 'local')
return new LocalBackupStorage(settings.directory);
const secrets =
explicitSecrets ??
(settings.credentials
Expand Down Expand Up @@ -158,7 +187,7 @@ export class BackupsService implements OnModuleInit, OnModuleDestroy {
try {
const value: StoredSettings = validateSettings(input);
if (value.provider === 'local')
value.directory = await this.storageDirectory(value.directory!);
value.directory = await this.storageDirectory(value.directory);
else {
await mkdir(resolve(this.config.get('uploadRoot', { infer: true })), {
recursive: true,
Expand Down Expand Up @@ -236,11 +265,11 @@ export class BackupsService implements OnModuleInit, OnModuleDestroy {
storage = await this.storage(settings);
for (const snapshot of await storage.list()) {
let verified: boolean | null = null;
if (!settings.provider || settings.provider === 'local') {
if (settings.provider === 'local') {
verified = false;
try {
await this.verifySnapshot(
join(settings.directory!, snapshot.id),
join(settings.directory, snapshot.id),
snapshot,
);
verified = true;
Expand All @@ -261,13 +290,12 @@ export class BackupsService implements OnModuleInit, OnModuleDestroy {
storage?.close?.();
}
}
const publicSettings = { ...settings };
delete publicSettings.credentials;
const publicSettings = settings ? validateSettings(settings) : null;
backups.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
return {
configured: !!settings,
provider: settings ? (settings.provider ?? 'local') : null,
settings: settings ? (publicSettings as BackupSettings) : null,
settings: publicSettings,
canBackup: supported && !!settings && !unavailable && !this.busy,
canRestore:
supported &&
Expand Down Expand Up @@ -425,6 +453,7 @@ export class BackupsService implements OnModuleInit, OnModuleDestroy {
? validateSettings(remote)
: directory
? {
provider: 'local',
directory: await this.storageDirectory(directory),
enabled: false,
intervalMinutes: 60,
Expand Down
18 changes: 12 additions & 6 deletions backend/src/backups/remote-backup-storage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { S3Client } from '@aws-sdk/client-s3';
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Readable } from 'node:stream';
import { S3BackupStorage } from './s3-backup-storage';
import { SftpBackupStorage, quoteSftp } from './sftp-backup-storage';
import { LocalBackupStorage } from './local-backup-storage';
function mockedClient(send: unknown) {
if (typeof send !== 'function')
throw new Error('Expected a mocked SDK send function.');
const client = new S3Client({ region: 'us-east-1' });
Object.defineProperty(client, 'send', { value: send });
return client;
}
const id = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa';
const snapshot = {
version: 1 as const,
Expand Down Expand Up @@ -61,7 +69,7 @@ describe('Backup storage publication and downloads', () => {
bucket: 'bucket',
prefix: 'prefix',
},
{ send } as never,
mockedClient(send),
);
expect(await storage.list()).toEqual([snapshot]);
const calls = send.mock.calls as unknown as [
Expand All @@ -83,9 +91,7 @@ describe('Backup storage publication and downloads', () => {
);
const storage = new S3BackupStorage(
{ enabled: false, intervalMinutes: 60, bucket: 'bucket' },
{
send,
} as never,
mockedClient(send),
);
await storage.save(snapshot, join(root, 'source'));
expect(keys).toEqual([
Expand All @@ -101,7 +107,7 @@ describe('Backup storage publication and downloads', () => {
await expect(
new S3BackupStorage(
{ enabled: false, intervalMinutes: 60, bucket: 'bucket' },
{ send } as never,
mockedClient(send),
).save(snapshot, join(root, 'source')),
).rejects.toThrow('S3');
expect(send).toHaveBeenCalledTimes(1);
Expand All @@ -118,7 +124,7 @@ describe('Backup storage publication and downloads', () => {
await expect(
new S3BackupStorage(
{ enabled: false, intervalMinutes: 60, bucket: 'bucket' },
{ send } as never,
mockedClient(send),
).load(id, join(root, 'loaded')),
).rejects.toThrow('verification');
expect(send).toHaveBeenCalledTimes(1);
Expand Down
48 changes: 31 additions & 17 deletions backend/src/backups/s3-backup-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,45 @@ import {
validateSnapshot,
type BackupStorage,
type BackupSettings,
type S3BackupSettings,
validateSettings,
type BackupSecrets,
type Snapshot,
} from './backup-storage';
export class S3BackupStorage implements BackupStorage {
private readonly client: S3Client;
private readonly settings: S3BackupSettings;
constructor(
private readonly settings: BackupSettings,
settings: BackupSettings,
clientOrSecrets: S3Client | BackupSecrets,
) {
this.client =
'send' in clientOrSecrets
? clientOrSecrets
: new S3Client({
region: settings.region,
endpoint: settings.endpoint || undefined,
forcePathStyle: settings.forcePathStyle,
credentials: {
accessKeyId: clientOrSecrets.accessKeyId!,
secretAccessKey: clientOrSecrets.secretAccessKey!,
},
requestHandler: {
requestTimeout: 300_000,
connectionTimeout: 15_000,
},
});
const validated = validateSettings({
...settings,
provider: settings.provider ?? 's3',
});
if (validated.provider !== 's3')
throw new BadRequestException('S3 backup settings required.');
this.settings = validated;
if ('send' in clientOrSecrets) {
this.client = clientOrSecrets;
return;
}
const { accessKeyId, secretAccessKey } = clientOrSecrets;
if (!accessKeyId || !secretAccessKey)
throw new BadRequestException('S3 backup credentials required.');
this.client = new S3Client({
region: this.settings.region,
endpoint: this.settings.endpoint || undefined,
forcePathStyle: this.settings.forcePathStyle,
credentials: {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
},
requestHandler: {
requestTimeout: 300_000,
connectionTimeout: 15_000,
},
});
}
close() {
this.client.destroy?.();
Expand Down
Loading
Loading