Files
pn-new-crm/src/lib/services/backup-destinations/s3.ts
Matt fe863a588e
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s
feat(backup): full DR bundle export + admin-configurable offsite destinations
Backend-agnostic disaster-recovery backup engine that runs on the current
storage backend (no storage cutover required):

- Full-bundle export: db.dump (pg_dump custom) + every storage blob +
  manifest.json with per-object SHA-256, streamed as a tar. Entry points:
  admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts.
- Admin-configurable push destinations (backup_destinations table, migration
  0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted
  path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM
  at rest; API returns only *IsSet markers.
- Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) +
  scripts/decrypt-backup.ts for restore.
- Wired the previously-dead database-backup cron to runScheduledBackupPush
  (push to enabled destinations, prune to retention, alert super-admins on
  failure).

Tests: 1608 unit/integration pass; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:23:42 +02:00

105 lines
3.5 KiB
TypeScript

/**
* S3-compatible backup transport — pushes the bundle to any S3 API endpoint
* (AWS S3, Backblaze B2, Wasabi, Cloudflare R2, MinIO). Reuses the `minio`
* client the storage backend already depends on, so no new SDK.
*/
import path from 'node:path';
import { Client as MinioClient } from 'minio';
import {
BACKUP_NAME_PREFIX,
sortBundlesNewestFirst,
type BackupTransport,
type S3DestConfig,
} from './types';
/** Split a configured endpoint (host or URL) into minio's endPoint/port/useSSL. */
export function parseS3Endpoint(
endpoint: string,
cfg: { useSSL?: boolean; port?: number },
): { endPoint: string; port?: number; useSSL: boolean } {
let host = endpoint.trim();
let useSSL = cfg.useSSL ?? true;
let port = cfg.port;
const m = /^(https?):\/\/([^/:]+)(?::(\d+))?/i.exec(host);
if (m) {
useSSL = m[1]!.toLowerCase() === 'https';
host = m[2]!;
if (m[3]) port = Number(m[3]);
} else {
host = host.replace(/\/.*$/, '');
}
return { endPoint: host, ...(port ? { port } : {}), useSSL };
}
export class S3Transport implements BackupTransport {
private readonly prefix: string;
constructor(private readonly cfg: S3DestConfig) {
// Normalise prefix to "" or "dir/".
const p = (cfg.prefix ?? '').replace(/^\/+|\/+$/g, '');
this.prefix = p ? `${p}/` : '';
}
private client(): MinioClient {
const { endPoint, port, useSSL } = parseS3Endpoint(this.cfg.endpoint, {
useSSL: this.cfg.useSSL,
port: this.cfg.port,
});
return new MinioClient({
endPoint,
...(port ? { port } : {}),
useSSL,
accessKey: this.cfg.accessKey,
secretKey: this.cfg.secretKey,
...(this.cfg.region ? { region: this.cfg.region } : {}),
});
}
async test(): Promise<void> {
const exists = await this.client().bucketExists(this.cfg.bucket);
if (!exists) throw new Error(`Bucket not found or not accessible: ${this.cfg.bucket}`);
}
async push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }> {
const key = `${this.prefix}${remoteName}`;
await this.client().fPutObject(this.cfg.bucket, key, localPath, {
'Content-Type': 'application/x-tar',
});
const { stat } = await import('node:fs/promises');
const s = await stat(localPath);
return { remoteRef: `s3://${this.cfg.bucket}/${key}`, bytes: s.size };
}
async prune(retentionCount: number | null): Promise<{ deleted: number }> {
if (retentionCount === null || retentionCount < 0) return { deleted: 0 };
const client = this.client();
const names = await this.listBundleKeys(client);
const sorted = sortBundlesNewestFirst(names.map((k) => path.posix.basename(k)));
const keepBasenames = new Set(sorted.slice(0, retentionCount));
const toDelete = names.filter(
(k) =>
path.posix.basename(k).startsWith(BACKUP_NAME_PREFIX) &&
!keepBasenames.has(path.posix.basename(k)),
);
for (const key of toDelete) await client.removeObject(this.cfg.bucket, key);
return { deleted: toDelete.length };
}
private listBundleKeys(client: MinioClient): Promise<string[]> {
return new Promise((resolve, reject) => {
const keys: string[] = [];
const stream = client.listObjectsV2(this.cfg.bucket, this.prefix, true);
stream.on('data', (obj) => {
if (obj.name && path.posix.basename(obj.name).startsWith(BACKUP_NAME_PREFIX)) {
keys.push(obj.name);
}
});
stream.on('error', reject);
stream.on('end', () => resolve(keys));
});
}
}