import { promisify } from 'node:util'; import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { basename, resolve } from 'node:path'; import type { BackupConfig } from '../../config/schema.js'; import type { Tool, ToolResult } from '../types.js'; import { backupInternals } from '../../backup/index.js'; const execFileAsync = promisify(execFile); type ExecRunner = (file: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) => Promise<{ stdout: string; stderr: string }>; export interface MinioShareDeps { execRunner?: ExecRunner; now?: () => Date; } function timestamp(now: Date): string { const year = now.getUTCFullYear(); const month = String(now.getUTCMonth() + 1).padStart(2, '0'); const day = String(now.getUTCDate()).padStart(2, '0'); const hour = String(now.getUTCHours()).padStart(2, '0'); const minute = String(now.getUTCMinutes()).padStart(2, '0'); const second = String(now.getUTCSeconds()).padStart(2, '0'); return `${year}${month}${day}_${hour}${minute}${second}`; } function parseShareUrl(stdout: string): string | null { const lines = stdout.split('\n').map((line) => line.trim()).filter(Boolean); for (const line of lines) { try { const parsed = JSON.parse(line) as Record; const share = typeof parsed.share === 'string' ? parsed.share : undefined; const url = typeof parsed.url === 'string' ? parsed.url : undefined; if (share) {return share;} if (url) {return url;} } catch { const match = line.match(/https?:\/\/\S+/); if (match) { return match[0]; } } } return null; } export const minioShareInternals = { parseShareUrl, timestamp, }; /** * Create a tool that uploads a local file to configured MinIO and returns a presigned download URL. * Uses backup.minio credentials so operators can reuse existing MinIO setup. */ export function createMinioShareTool(config: BackupConfig, deps?: MinioShareDeps): Tool { return { name: 'minio.share', description: 'Upload a local file to MinIO and return a temporary share link.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Local file path to upload', }, expires: { type: 'string', description: 'Share link expiry for mc (e.g. "24h", "7d"). Default: 24h', }, prefix: { type: 'string', description: 'Object-key prefix under the bucket. Default: "shared"', }, object_key: { type: 'string', description: 'Explicit object key (overrides generated key)', }, }, required: ['path'], }, execute: async (rawArgs: unknown): Promise => { const args = rawArgs as { path: string; expires?: string; prefix?: string; object_key?: string }; const minio = config.minio; if (!minio.enabled) { return { success: false, output: '', error: 'MinIO sharing requires backup.minio.enabled=true' }; } if (!minio.endpoint || !minio.access_key || !minio.secret_key || !minio.bucket) { return { success: false, output: '', error: 'Missing MinIO credentials in backup.minio (endpoint/access_key/secret_key/bucket)', }; } const localPath = resolve(args.path); if (!existsSync(localPath)) { return { success: false, output: '', error: `File not found: ${localPath}` }; } const now = deps?.now ? deps.now() : new Date(); const prefix = args.prefix ?? 'shared'; const fileName = basename(localPath); const objectKey = args.object_key ?? backupInternals.buildObjectKey(prefix, `${timestamp(now)}_${fileName}`); const alias = 'flynnshare'; const remotePath = `${alias}/${minio.bucket}/${objectKey}`; const expires = args.expires ?? '24h'; const host = backupInternals.buildMinioHost({ endpoint: minio.endpoint, accessKey: minio.access_key, secretKey: minio.secret_key, secure: minio.secure, }); const env = { ...process.env, [`MC_HOST_${alias}`]: host }; const runner = deps?.execRunner ?? (async (file: string, cmdArgs: string[], options?: { env?: NodeJS.ProcessEnv }) => { return execFileAsync(file, cmdArgs, options); }); try { await runner('mc', ['mb', '--ignore-existing', `${alias}/${minio.bucket}`], { env }); await runner('mc', ['cp', localPath, remotePath], { env }); const { stdout } = await runner('mc', ['share', 'download', '--json', '--expire', expires, remotePath], { env }); const shareUrl = parseShareUrl(typeof stdout === 'string' ? stdout : stdout.toString('utf-8')); if (!shareUrl) { return { success: false, output: '', error: 'Failed to parse MinIO share URL from mc output' }; } return { success: true, output: `Shared file uploaded.\nPath: ${localPath}\nObject: ${objectKey}\nURL: ${shareUrl}`, }; } catch (error) { return { success: false, output: '', error: error instanceof Error ? error.message : String(error), }; } }, }; }