feat(tools): add minio.share upload and presigned link tool
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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<string, unknown>;
|
||||
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<ToolResult> => {
|
||||
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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user