Ctrl K
ComunidadGuías…

src/adapters/storage/object-storage.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { ossPutHeaders } from './oss-signature';
// 媒体存储适配(聊天图片/语音/附件落盘或落桶取永久 URL)。
// 默认 local:开箱即用,写到本机 data/uploads 并由 /uploads/* 公开读取;生产可切 COS,OSS 也支持;S3 预留。
// 设计:业务桶则 URL 即业务 CDN 地址(加商品零转存),中枢桶/本地存储则中枢掌控留存;URL 永久不清理,供完整追溯 + 多模态读图/听音。
// COS 请求签名(q-sign-algorithm=sha1)按官方算法手写(零依赖,避免上线 npm install)。
// ⚠ 签名为纯计算、本机无真桶可验——首次接真桶上传若 403,对照 COS 返回的 XML 报文核对 host/header-list 即可。
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, extname, resolve } from 'node:path';
import type { StorageBucket } from '../../core/contracts/types';

const EXT: Record<string, string> = {
  'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
  'audio/webm': 'webm', 'audio/mpeg': 'mp3', 'audio/mp3': 'mp3', 'audio/mp4': 'm4a', 'audio/x-m4a': 'm4a',
  'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/ogg': 'ogg', 'audio/flac': 'flac',
  'text/plain': 'txt', 'text/markdown': 'md', 'text/csv': 'csv', 'text/tab-separated-values': 'tsv', 'text/html': 'html',
  'text/x-log': 'log', 'text/x-ini': 'ini', 'text/x-conf': 'conf',
  'application/json': 'json', 'application/x-ndjson': 'jsonl', 'application/xml': 'xml', 'text/xml': 'xml',
  'application/yaml': 'yaml', 'application/x-yaml': 'yaml', 'text/yaml': 'yaml',
  'application/sql': 'sql',
  'application/pdf': 'pdf',
  'application/msword': 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
  'application/vnd.ms-excel': 'xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
  'application/vnd.ms-powerpoint': 'ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
  'application/zip': 'zip', 'application/x-zip-compressed': 'zip', 'application/x-rar-compressed': 'rar', 'application/x-7z-compressed': '7z',
};
const MIME_BY_EXT: Record<string, string> = Object.fromEntries(Object.entries(EXT).map(([mime, ext]) => [`.${ext}`, mime]));

// 上传约束(聊天上传 / 企微入站图片共用):可落桶的媒体 MIME 与原始请求体上限。
export const UPLOAD_MIME = /^image\/(png|jpe?g|webp|gif)$/;
export const AUDIO_UPLOAD_MIME = /^audio\/(webm|mpeg|mp3|mp4|x-m4a|wav|x-wav|ogg|flac)$/;
export const FILE_UPLOAD_MIME = /^(text\/(plain|markdown|csv|tab-separated-values|html|xml|yaml|x-log|x-ini|x-conf)|application\/(json|x-ndjson|xml|ya?ml|x-yaml|sql|pdf|msword|vnd\.openxmlformats-officedocument\.wordprocessingml\.document|vnd\.ms-excel|vnd\.openxmlformats-officedocument\.spreadsheetml\.sheet|vnd\.ms-powerpoint|vnd\.openxmlformats-officedocument\.presentationml\.presentation|zip|x-zip-compressed|x-rar-compressed|x-7z-compressed))$/;
export const UPLOAD_MAX_BYTES = 9 * 1024 * 1024; // 原始请求体上限 ≈ 6.5MB 文件(base64 膨胀 ~33%)
export const AUDIO_UPLOAD_MAX_BYTES = 16 * 1024 * 1024; // 原始请求体上限 ≈ 12MB 录音
export const FILE_UPLOAD_MAX_BYTES = 28 * 1024 * 1024; // 原始请求体上限 ≈ 20MB 文件
export const LOCAL_UPLOAD_URL_PREFIX = '/uploads';
export const LOCAL_UPLOAD_DIR = 'data/uploads';

export function localStorageBucket(publicBaseUrl: string): StorageBucket {
  return {
    name: 'local',
    kind: 'local',
    region: '',
    bucket: 'local',
    access_key: '',
    secret_key: '',
    public_base_url: publicBaseUrl.replace(/\/+$/, '') + LOCAL_UPLOAD_URL_PREFIX,
    path_prefix: 'bailing/chat',
    enabled: true,
    description: '内置本地媒体存储',
  };
}

export function storageBucketForRuntime(bucket: StorageBucket | null | undefined, publicBaseUrl: string): StorageBucket {
  if (!bucket || !bucket.enabled) return localStorageBucket(publicBaseUrl);
  if (bucket.kind !== 'local') return bucket;
  const local = localStorageBucket(publicBaseUrl);
  return {
    ...bucket,
    bucket: bucket.bucket || local.bucket,
    public_base_url: bucket.public_base_url || local.public_base_url,
    path_prefix: bucket.path_prefix || local.path_prefix,
  };
}

// COS urlencode:除 A-Za-z0-9-_.~ 外全部百分号编码,大写十六进制(encodeURIComponent 不编码 !'()* ,补上)
function rfc3986(s: string): string {
  return encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
}
function sha1hex(s: string): string { return createHash('sha1').update(s, 'utf8').digest('hex'); }
function hmacSha1hex(key: string, s: string): string { return createHmac('sha1', key).update(s, 'utf8').digest('hex'); }

function cosHost(b: StorageBucket): string {
  if (b.endpoint) return b.endpoint.replace(/^https?:\/\//, '').replace(/\/+$/, '');
  return `${b.bucket}.cos.${b.region}.myqcloud.com`;
}

/** COS 请求签名(签 host + content-type + x-cos-acl)。返回 Authorization 头值。 */
function cosAuth(b: StorageBucket, method: string, key: string, signedHeaders: Record<string, string>, nowSec: number): string {
  const start = nowSec - 60, end = nowSec + 900;
  const keyTime = `${start};${end}`;
  const signKey = hmacSha1hex(b.secret_key, keyTime);
  const lowerKeys = Object.keys(signedHeaders).map((k) => k.toLowerCase()).sort();
  const headerList = lowerKeys.join(';');
  const httpHeaders = lowerKeys.map((k) => `${rfc3986(k)}=${rfc3986(signedHeaders[k]!)}`).join('&');
  const fmt = `${method.toLowerCase()}\n/${key}\n\n${httpHeaders}\n`;       // 无 query → HttpParameters 段为空
  const stringToSign = `sha1\n${keyTime}\n${sha1hex(fmt)}\n`;
  const signature = hmacSha1hex(signKey, stringToSign);
  return `q-sign-algorithm=sha1&q-ak=${b.access_key}&q-sign-time=${keyTime}&q-key-time=${keyTime}` +
    `&q-header-list=${headerList}&q-url-param-list=&q-signature=${signature}`;
}

/** 生成对象键:<prefix>/<entry>/<32hex>.<ext>,全安全字符(无需再 urlencode)。 */
export function objectKey(b: StorageBucket, entryKey: string, mime: string): string {
  const ext = EXT[mime] || 'bin';
  const prefix = (b.path_prefix || 'bailing/chat').replace(/^\/+|\/+$/g, '');
  const safeEntry = entryKey.replace(/[^a-z0-9_-]/gi, '').slice(0, 32) || 'chat';
  return `${prefix}/${safeEntry}/${randomBytes(16).toString('hex')}.${ext}`;
}

function localUploadBase(root: string): string {
  return resolve(root, LOCAL_UPLOAD_DIR);
}

export function localObjectFile(root: string, key: string): { file: string; contentType: string } | null {
  const clean = key.replace(/^\/+/, '');
  if (!clean || clean.includes('..') || clean.includes('\\')) return null;
  const base = localUploadBase(root);
  const file = resolve(base, clean);
  if (!file.startsWith(base + '/')) return null;
  return { file, contentType: MIME_BY_EXT[extname(file).toLowerCase()] ?? 'application/octet-stream' };
}

/** 上传字节到存储,返回永久公开 URL。local 开箱即用;cos 已实现;OSS 也支持;S3 预留。 */
export async function putObject(b: StorageBucket, key: string, body: Buffer, contentType: string, opts: { root?: string } = {}): Promise<string> {
  if (b.kind === 'local') {
    if (!opts.root) throw new Error('本地媒体存储需要 root');
    const target = localObjectFile(opts.root, key);
    if (!target) throw new Error('本地媒体存储 key 非法');
    await mkdir(dirname(target.file), { recursive: true });
    await writeFile(target.file, body);
    const base = (b.public_base_url || LOCAL_UPLOAD_URL_PREFIX).replace(/\/+$/, '');
    return `${base}/${key}`;
  }
  if (b.kind === 'oss') {
    const endpoint = b.endpoint || `https://${b.bucket}.oss-${b.region.replace(/^oss-/, '')}.aliyuncs.com`;
    const target = new URL(endpoint.includes('://') ? endpoint : `https://${endpoint}`);
    if (target.protocol !== 'https:' || target.username || target.password || target.search || target.hash || target.pathname !== '/') throw new Error('Invalid OSS endpoint');
    const path = key.split('/').map(rfc3986).join('/');
    const response = await fetch(`${target.origin}/${path}`, { method: 'PUT', redirect: 'error',
      headers: ossPutHeaders(b, key, body, contentType), body, signal: AbortSignal.timeout(20000) });
    if (!response.ok) { await response.body?.cancel(); throw new Error(`OSS PUT ${response.status}`); }
    await response.body?.cancel();
    return `${(b.public_base_url || target.origin).replace(/\/+$/, '')}/${path}`;
  }
  if (b.kind !== 'cos') throw new Error(`对象存储类型 ${b.kind} 暂未实现(当前支持 cos)`);
  const host = cosHost(b);
  const signed = { host, 'content-type': contentType, 'x-cos-acl': 'public-read' };
  const auth = cosAuth(b, 'put', key, signed, Math.floor(Date.now() / 1000));
  const r = await fetch(`https://${host}/${key}`, {
    method: 'PUT', redirect: 'error',
    headers: { authorization: auth, 'content-type': contentType, 'x-cos-acl': 'public-read' },
    body,
    signal: AbortSignal.timeout(20000),
  });
  if (r.status < 200 || r.status >= 300) {
    const t = (await r.text().catch(() => '')).slice(0, 300);
    throw new Error(`COS PUT ${r.status}: ${t}`);
  }
  const base = (b.public_base_url || `https://${host}`).replace(/\/+$/, '');
  return `${base}/${key}`;
}