/** * 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 { 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 { 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)); }); } }