09-uploadManager.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. /**
  2. * 云函数:uploadManager
  3. * 签发七牛直传 token。文件本体由浏览器直接上传到七牛,
  4. * 云函数只负责生成受账号隔离约束的上传凭证,不接收大文件内容。
  5. *
  6. * 支持 action:
  7. * createUploadToken -> { uploadUrl, token, key, url, mimeType, kind, expiresAt }
  8. */
  9. const { requireSession, assertRequestedUserMatchesSession } = require('./_session');
  10. async function handler(request, response) {
  11. try {
  12. const action = pickParam(request, 'action') || 'createUploadToken';
  13. if (action !== 'createUploadToken') {
  14. return response.json({ code: 400, success: false, error: `Unknown action: ${action}` });
  15. }
  16. const session = await requireSession(request, Psql);
  17. assertRequestedUserMatchesSession(request, session);
  18. const userId = session.userId;
  19. const filename = String(pickParam(request, 'filename', 'fileName') || `asset-${Date.now()}`);
  20. const mimeType = String(pickParam(request, 'mimeType', 'contentType') || 'application/octet-stream');
  21. const requestedKind = String(pickParam(request, 'kind') || '').trim();
  22. const size = Number(pickParam(request, 'size') || 0);
  23. const bizId = String(pickParam(request, 'bizId') || '').trim();
  24. const kind = normalizeKind(requestedKind, mimeType);
  25. const key = buildUserAssetKey(filename, kind, userId, bizId);
  26. const { token, deadline } = await buildQiniuUploadToken(key);
  27. const url = `${QINIU_CDN_DOMAIN.replace(/\/$/, '')}/${key}`;
  28. response.json({
  29. code: 200,
  30. success: true,
  31. data: {
  32. uploadUrl: QINIU_UPLOAD_URL,
  33. token,
  34. key,
  35. url,
  36. mimeType,
  37. kind,
  38. bucket: QINIU_BUCKET,
  39. size,
  40. expiresAt: deadline * 1000,
  41. },
  42. });
  43. } catch (error) {
  44. console.error('uploadManager failed:', error && error.message ? error.message : error);
  45. response.json({
  46. code: 500,
  47. success: false,
  48. error: error && error.message ? error.message : 'uploadManager failed',
  49. });
  50. }
  51. }
  52. const QINIU_ACCESS_KEY = readEnv('QINIU_AK') || readEnv('QINIU_ACCESS_KEY') || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
  53. const QINIU_SECRET_KEY = readEnv('QINIU_SK') || readEnv('QINIU_SECRET_KEY') || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
  54. const QINIU_BUCKET = readEnv('QINIU_BUCKET') || 'nova-repos';
  55. const QINIU_CDN_DOMAIN = readEnv('QINIU_CDN_DOMAIN') || 'https://repos.fmode.cn';
  56. const QINIU_CDN_PREFIX = readEnv('QINIU_CDN_PREFIX') || 'x/openclaw-skills';
  57. const QINIU_UPLOAD_URL = readEnv('QINIU_UPLOAD_URL') || 'https://up-z2.qiniup.com';
  58. async function buildQiniuUploadToken(key) {
  59. if (!QINIU_ACCESS_KEY || !QINIU_SECRET_KEY) {
  60. throw new Error('Qiniu AK/SK is not configured');
  61. }
  62. const deadline = Math.floor(Date.now() / 1000) + 3600;
  63. const putPolicy = {
  64. scope: `${QINIU_BUCKET}:${key}`,
  65. deadline,
  66. };
  67. const encodedPutPolicy = toBase64Url(JSON.stringify(putPolicy));
  68. const sign = await hmacSha1Base64Url(QINIU_SECRET_KEY, encodedPutPolicy);
  69. return {
  70. token: `${QINIU_ACCESS_KEY}:${sign}:${encodedPutPolicy}`,
  71. deadline,
  72. };
  73. }
  74. function buildUserAssetKey(fileName, kind, userId, bizId) {
  75. const rawName = String(fileName || `asset-${Date.now()}`);
  76. const extMatch = rawName.match(/(\.[a-z0-9]+)$/i);
  77. const ext = extMatch ? extMatch[1].toLowerCase() : '';
  78. const safeExt = ext && /^[.a-z0-9]+$/i.test(ext) ? ext : '';
  79. const baseName = rawName
  80. .replace(/\\/g, '/')
  81. .split('/')
  82. .pop()
  83. .replace(/\.[^.]*$/, '')
  84. .replace(/[^a-zA-Z0-9_-]/g, '_') || `asset-${Date.now()}`;
  85. const date = new Date();
  86. const yyyy = date.getFullYear();
  87. const mm = String(date.getMonth() + 1).padStart(2, '0');
  88. const dd = String(date.getDate()).padStart(2, '0');
  89. const uuid = generateId();
  90. const partition = safePathSegment(bizId) || 'free';
  91. return `users/${safePathSegment(userId)}/${kind}/${yyyy}/${mm}/${partition}/${yyyy}${mm}${dd}-${Date.now()}-${uuid}-${baseName}${safeExt}`;
  92. }
  93. function normalizeKind(kind, mimeType) {
  94. if (kind === 'audio' || kind === 'video' || kind === 'image' || kind === 'export' || kind === 'snapshot') return kind;
  95. if (mimeType.startsWith('audio/')) return 'audio';
  96. if (mimeType.startsWith('video/')) return 'video';
  97. return 'image';
  98. }
  99. function safePathSegment(value) {
  100. return String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
  101. }
  102. function generateId() {
  103. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  104. let value = '';
  105. for (let i = 0; i < 8; i += 1) value += chars.charAt(Math.floor(Math.random() * chars.length));
  106. return value;
  107. }
  108. function toBase64Url(input) {
  109. return base64UrlEncode(bytesFromString(input));
  110. }
  111. function pickParam(request, ...names) {
  112. const sources = [request.params, request.body, request];
  113. for (const src of sources) {
  114. if (!src || typeof src !== 'object') continue;
  115. for (const n of names) {
  116. const v = src[n];
  117. if (v !== undefined && v !== null && v !== '') return v;
  118. }
  119. }
  120. return null;
  121. }
  122. function readEnv(name) {
  123. if (typeof process !== 'undefined' && process.env && process.env[name]) {
  124. return process.env[name];
  125. }
  126. return '';
  127. }
  128. async function hmacSha1Base64Url(secret, message) {
  129. const subtle = typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle;
  130. if (subtle && typeof TextEncoder !== 'undefined') {
  131. const encoder = new TextEncoder();
  132. const key = await subtle.importKey(
  133. 'raw',
  134. encoder.encode(secret),
  135. { name: 'HMAC', hash: 'SHA-1' },
  136. false,
  137. ['sign']
  138. );
  139. const signature = await subtle.sign('HMAC', key, encoder.encode(message));
  140. return base64UrlEncode(new Uint8Array(signature));
  141. }
  142. if (typeof require === 'function') {
  143. const nodeCrypto = require('crypto');
  144. return nodeCrypto
  145. .createHmac('sha1', secret)
  146. .update(message)
  147. .digest('base64')
  148. .replace(/\+/g, '-')
  149. .replace(/\//g, '_');
  150. }
  151. return base64UrlEncode(hmacSha1Bytes(bytesFromString(secret), bytesFromString(message)));
  152. }
  153. function bytesFromString(input) {
  154. if (typeof TextEncoder !== 'undefined') {
  155. return new TextEncoder().encode(input);
  156. }
  157. if (typeof Buffer !== 'undefined') {
  158. return Buffer.from(input, 'utf8');
  159. }
  160. throw new Error('No UTF-8 encoder is available in this cloud runtime');
  161. }
  162. function base64UrlEncode(bytes) {
  163. let base64;
  164. if (typeof Buffer !== 'undefined') {
  165. base64 = Buffer.from(bytes).toString('base64');
  166. } else if (typeof btoa === 'function') {
  167. let binary = '';
  168. for (let i = 0; i < bytes.length; i++) {
  169. binary += String.fromCharCode(bytes[i]);
  170. }
  171. base64 = btoa(binary);
  172. } else {
  173. base64 = base64EncodeBytes(bytes);
  174. }
  175. return base64
  176. .replace(/\+/g, '-')
  177. .replace(/\//g, '_');
  178. }
  179. function base64EncodeBytes(bytes) {
  180. const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
  181. let output = '';
  182. for (let i = 0; i < bytes.length; i += 3) {
  183. const b1 = bytes[i];
  184. const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;
  185. const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;
  186. const triplet = (b1 << 16) | (b2 << 8) | b3;
  187. output += alphabet[(triplet >> 18) & 0x3f];
  188. output += alphabet[(triplet >> 12) & 0x3f];
  189. output += i + 1 < bytes.length ? alphabet[(triplet >> 6) & 0x3f] : '=';
  190. output += i + 2 < bytes.length ? alphabet[triplet & 0x3f] : '=';
  191. }
  192. return output;
  193. }
  194. function hmacSha1Bytes(secretBytes, messageBytes) {
  195. const blockSize = 64;
  196. let key = toByteArray(secretBytes);
  197. if (key.length > blockSize) {
  198. key = sha1Bytes(key);
  199. }
  200. const paddedKey = new Uint8Array(blockSize);
  201. paddedKey.set(key);
  202. const innerPad = new Uint8Array(blockSize);
  203. const outerPad = new Uint8Array(blockSize);
  204. for (let i = 0; i < blockSize; i++) {
  205. innerPad[i] = paddedKey[i] ^ 0x36;
  206. outerPad[i] = paddedKey[i] ^ 0x5c;
  207. }
  208. return sha1Bytes(concatBytes(outerPad, sha1Bytes(concatBytes(innerPad, messageBytes))));
  209. }
  210. function sha1Bytes(inputBytes) {
  211. const bytes = toByteArray(inputBytes);
  212. const bitLength = bytes.length * 8;
  213. const paddedLength = (((bytes.length + 9 + 63) >> 6) << 6);
  214. const padded = new Uint8Array(paddedLength);
  215. padded.set(bytes);
  216. padded[bytes.length] = 0x80;
  217. const view = new DataView(padded.buffer);
  218. view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false);
  219. view.setUint32(paddedLength - 4, bitLength >>> 0, false);
  220. let h0 = 0x67452301;
  221. let h1 = 0xefcdab89;
  222. let h2 = 0x98badcfe;
  223. let h3 = 0x10325476;
  224. let h4 = 0xc3d2e1f0;
  225. const w = new Uint32Array(80);
  226. for (let offset = 0; offset < paddedLength; offset += 64) {
  227. for (let i = 0; i < 16; i++) {
  228. w[i] = view.getUint32(offset + i * 4, false);
  229. }
  230. for (let i = 16; i < 80; i++) {
  231. w[i] = rotateLeft(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
  232. }
  233. let a = h0;
  234. let b = h1;
  235. let c = h2;
  236. let d = h3;
  237. let e = h4;
  238. for (let i = 0; i < 80; i++) {
  239. let f;
  240. let k;
  241. if (i < 20) {
  242. f = (b & c) | (~b & d);
  243. k = 0x5a827999;
  244. } else if (i < 40) {
  245. f = b ^ c ^ d;
  246. k = 0x6ed9eba1;
  247. } else if (i < 60) {
  248. f = (b & c) | (b & d) | (c & d);
  249. k = 0x8f1bbcdc;
  250. } else {
  251. f = b ^ c ^ d;
  252. k = 0xca62c1d6;
  253. }
  254. const temp = (rotateLeft(a, 5) + f + e + k + w[i]) >>> 0;
  255. e = d;
  256. d = c;
  257. c = rotateLeft(b, 30);
  258. b = a;
  259. a = temp;
  260. }
  261. h0 = (h0 + a) >>> 0;
  262. h1 = (h1 + b) >>> 0;
  263. h2 = (h2 + c) >>> 0;
  264. h3 = (h3 + d) >>> 0;
  265. h4 = (h4 + e) >>> 0;
  266. }
  267. const output = new Uint8Array(20);
  268. const outputView = new DataView(output.buffer);
  269. outputView.setUint32(0, h0, false);
  270. outputView.setUint32(4, h1, false);
  271. outputView.setUint32(8, h2, false);
  272. outputView.setUint32(12, h3, false);
  273. outputView.setUint32(16, h4, false);
  274. return output;
  275. }
  276. function concatBytes(a, b) {
  277. const left = toByteArray(a);
  278. const right = toByteArray(b);
  279. const out = new Uint8Array(left.length + right.length);
  280. out.set(left);
  281. out.set(right, left.length);
  282. return out;
  283. }
  284. function toByteArray(bytes) {
  285. return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
  286. }
  287. function rotateLeft(value, bits) {
  288. return ((value << bits) | (value >>> (32 - bits))) >>> 0;
  289. }