/** * Cloud function: uploadManager * Signs Qiniu direct-upload tokens. File bytes are uploaded from the browser * directly to Qiniu, so the cloud function never handles large payloads. * * actions: * createUploadToken -> { uploadUrl, token, key, url, mimeType, kind, expiresAt } */ async function handler(request, response) { try { const action = pickParam(request, 'action') || 'createUploadToken'; if (action !== 'createUploadToken') { return response.json({ code: 400, success: false, error: `Unknown action: ${action}` }); } const filename = String(pickParam(request, 'filename', 'fileName') || `asset-${Date.now()}`); const mimeType = String(pickParam(request, 'mimeType', 'contentType') || 'application/octet-stream'); const requestedKind = String(pickParam(request, 'kind') || '').trim(); const size = Number(pickParam(request, 'size') || 0); const kind = normalizeKind(requestedKind, mimeType); const key = buildDigitalHumanAssetKey(filename, kind); const { token, deadline } = await buildQiniuUploadToken(key); const url = `${QINIU_CDN_DOMAIN.replace(/\/$/, '')}/${key}`; response.json({ code: 200, success: true, data: { uploadUrl: QINIU_UPLOAD_URL, token, key, url, mimeType, kind, size, expiresAt: deadline * 1000, }, }); } catch (error) { console.error('uploadManager failed:', error && error.message ? error.message : error); response.json({ code: 500, success: false, error: error && error.message ? error.message : 'uploadManager failed', }); } } const QINIU_ACCESS_KEY = readEnv('QINIU_AK') || readEnv('QINIU_ACCESS_KEY') || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE'; const QINIU_SECRET_KEY = readEnv('QINIU_SK') || readEnv('QINIU_SECRET_KEY') || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX'; const QINIU_BUCKET = readEnv('QINIU_BUCKET') || 'nova-repos'; const QINIU_CDN_DOMAIN = readEnv('QINIU_CDN_DOMAIN') || 'https://repos.fmode.cn'; const QINIU_CDN_PREFIX = readEnv('QINIU_CDN_PREFIX') || 'x/openclaw-skills'; const QINIU_UPLOAD_URL = readEnv('QINIU_UPLOAD_URL') || 'https://up-z2.qiniup.com'; async function buildQiniuUploadToken(key) { if (!QINIU_ACCESS_KEY || !QINIU_SECRET_KEY) { throw new Error('Qiniu AK/SK is not configured'); } const deadline = Math.floor(Date.now() / 1000) + 3600; const putPolicy = { scope: `${QINIU_BUCKET}:${key}`, deadline, }; const encodedPutPolicy = toBase64Url(JSON.stringify(putPolicy)); const sign = await hmacSha1Base64Url(QINIU_SECRET_KEY, encodedPutPolicy); return { token: `${QINIU_ACCESS_KEY}:${sign}:${encodedPutPolicy}`, deadline, }; } function buildDigitalHumanAssetKey(fileName, kind) { const rawName = String(fileName || `asset-${Date.now()}`); const extMatch = rawName.match(/(\.[a-z0-9]+)$/i); const ext = extMatch ? extMatch[1].toLowerCase() : ''; const safeExt = ext && /^[.a-z0-9]+$/i.test(ext) ? ext : ''; const baseName = rawName .replace(/\\/g, '/') .split('/') .pop() .replace(/\.[^.]*$/, '') .replace(/[^a-zA-Z0-9_-]/g, '_') || `asset-${Date.now()}`; const date = new Date(); const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); const timestamp = `${yyyy}${mm}${dd}-${Date.now()}`; return `${QINIU_CDN_PREFIX}/digital-human/${kind}/${yyyy}${mm}${dd}/${timestamp}-${baseName}${safeExt}`; } function normalizeKind(kind, mimeType) { if (kind === 'audio' || kind === 'video' || kind === 'image') return kind; if (mimeType.startsWith('audio/')) return 'audio'; if (mimeType.startsWith('video/')) return 'video'; return 'image'; } function toBase64Url(input) { return base64UrlEncode(bytesFromString(input)); } function pickParam(request, ...names) { const sources = [request.params, request.body, request]; for (const src of sources) { if (!src || typeof src !== 'object') continue; for (const n of names) { const v = src[n]; if (v !== undefined && v !== null && v !== '') return v; } } return null; } function readEnv(name) { if (typeof process !== 'undefined' && process.env && process.env[name]) { return process.env[name]; } return ''; } async function hmacSha1Base64Url(secret, message) { const subtle = typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle; if (subtle && typeof TextEncoder !== 'undefined') { const encoder = new TextEncoder(); const key = await subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'] ); const signature = await subtle.sign('HMAC', key, encoder.encode(message)); return base64UrlEncode(new Uint8Array(signature)); } if (typeof require === 'function') { const nodeCrypto = require('crypto'); return nodeCrypto .createHmac('sha1', secret) .update(message) .digest('base64') .replace(/\+/g, '-') .replace(/\//g, '_'); } return base64UrlEncode(hmacSha1Bytes(bytesFromString(secret), bytesFromString(message))); } function bytesFromString(input) { if (typeof TextEncoder !== 'undefined') { return new TextEncoder().encode(input); } if (typeof Buffer !== 'undefined') { return Buffer.from(input, 'utf8'); } throw new Error('No UTF-8 encoder is available in this cloud runtime'); } function base64UrlEncode(bytes) { let base64; if (typeof Buffer !== 'undefined') { base64 = Buffer.from(bytes).toString('base64'); } else if (typeof btoa === 'function') { let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } base64 = btoa(binary); } else { base64 = base64EncodeBytes(bytes); } return base64 .replace(/\+/g, '-') .replace(/\//g, '_'); } function base64EncodeBytes(bytes) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; let output = ''; for (let i = 0; i < bytes.length; i += 3) { const b1 = bytes[i]; const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0; const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0; const triplet = (b1 << 16) | (b2 << 8) | b3; output += alphabet[(triplet >> 18) & 0x3f]; output += alphabet[(triplet >> 12) & 0x3f]; output += i + 1 < bytes.length ? alphabet[(triplet >> 6) & 0x3f] : '='; output += i + 2 < bytes.length ? alphabet[triplet & 0x3f] : '='; } return output; } function hmacSha1Bytes(secretBytes, messageBytes) { const blockSize = 64; let key = toByteArray(secretBytes); if (key.length > blockSize) { key = sha1Bytes(key); } const paddedKey = new Uint8Array(blockSize); paddedKey.set(key); const innerPad = new Uint8Array(blockSize); const outerPad = new Uint8Array(blockSize); for (let i = 0; i < blockSize; i++) { innerPad[i] = paddedKey[i] ^ 0x36; outerPad[i] = paddedKey[i] ^ 0x5c; } return sha1Bytes(concatBytes(outerPad, sha1Bytes(concatBytes(innerPad, messageBytes)))); } function sha1Bytes(inputBytes) { const bytes = toByteArray(inputBytes); const bitLength = bytes.length * 8; const paddedLength = (((bytes.length + 9 + 63) >> 6) << 6); const padded = new Uint8Array(paddedLength); padded.set(bytes); padded[bytes.length] = 0x80; const view = new DataView(padded.buffer); view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false); view.setUint32(paddedLength - 4, bitLength >>> 0, false); let h0 = 0x67452301; let h1 = 0xefcdab89; let h2 = 0x98badcfe; let h3 = 0x10325476; let h4 = 0xc3d2e1f0; const w = new Uint32Array(80); for (let offset = 0; offset < paddedLength; offset += 64) { for (let i = 0; i < 16; i++) { w[i] = view.getUint32(offset + i * 4, false); } for (let i = 16; i < 80; i++) { w[i] = rotateLeft(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); } let a = h0; let b = h1; let c = h2; let d = h3; let e = h4; for (let i = 0; i < 80; i++) { let f; let k; if (i < 20) { f = (b & c) | (~b & d); k = 0x5a827999; } else if (i < 40) { f = b ^ c ^ d; k = 0x6ed9eba1; } else if (i < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8f1bbcdc; } else { f = b ^ c ^ d; k = 0xca62c1d6; } const temp = (rotateLeft(a, 5) + f + e + k + w[i]) >>> 0; e = d; d = c; c = rotateLeft(b, 30); b = a; a = temp; } h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0; h4 = (h4 + e) >>> 0; } const output = new Uint8Array(20); const outputView = new DataView(output.buffer); outputView.setUint32(0, h0, false); outputView.setUint32(4, h1, false); outputView.setUint32(8, h2, false); outputView.setUint32(12, h3, false); outputView.setUint32(16, h4, false); return output; } function concatBytes(a, b) { const left = toByteArray(a); const right = toByteArray(b); const out = new Uint8Array(left.length + right.length); out.set(left); out.set(right, left.length); return out; } function toByteArray(bytes) { return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); } function rotateLeft(value, bits) { return ((value << bits) | (value >>> (32 - bits))) >>> 0; }