diff --git a/src/main.js b/src/main.js index 37a5d0e..60dceb2 100644 --- a/src/main.js +++ b/src/main.js @@ -1,77 +1,315 @@ (function (Scratch) { 'use strict'; - // 非沙箱守卫:确保扩展有权限发起网络请求 + // 非沙箱守卫 if (!Scratch.extensions.unsandboxed) { - throw new Error('S3 扩展必须运行在非沙箱模式下'); + throw new Error('S3 扩展必须运行在非沙箱模式下(加载时请勾选 Run without sandbox)'); } + const enc = new TextEncoder(); + + // ---------- 工具函数 ---------- + async function sha256Hex(data) { + const buf = typeof data === 'string' ? enc.encode(data) : data; + const hash = await crypto.subtle.digest('SHA-256', buf); + return [...new Uint8Array(hash)] + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } + + async function hmac(key, data) { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + typeof key === 'string' ? enc.encode(key) : key, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + return new Uint8Array( + await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(data)) + ); + } + + // RFC 3986 编码 + function uriEncode(str, encodeSlash = true) { + return String(str).split('').map(ch => { + if (/[A-Za-z0-9\-_.~]/.test(ch)) return ch; + if (ch === '/' && !encodeSlash) return ch; + return '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'); + }).join(''); + } + + // 把 ArrayBuffer 转 hex + function bufToHex(buf) { + return [...new Uint8Array(buf)] + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } + + // ---------- SigV4 签名 ---------- + async function signRequest({ + method, host, path, query = {}, headers = {}, + payloadHash, region, service, accessKey, secretKey + }) { + const now = new Date(); + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); + const dateStamp = amzDate.slice(0, 8); + + // 规范化查询字符串 + const canonicalQuery = Object.keys(query).sort() + .map(k => `${uriEncode(k)}=${uriEncode(query[k])}`) + .join('&'); + + // 规范化 URI(路径中的 / 不编码,但每段单独编码) + const canonicalUri = path.split('/').map(p => uriEncode(p)).join('/'); + + // 合并头,统一小写 + const allHeaders = { + host, + 'x-amz-date': amzDate, + 'x-amz-content-sha256': payloadHash, + ...headers + }; + const lower = {}; + Object.keys(allHeaders).forEach(k => { + lower[k.toLowerCase()] = String(allHeaders[k]).trim(); + }); + const sortedKeys = Object.keys(lower).sort(); + const canonicalHeaders = sortedKeys.map(k => `${k}:${lower[k]}\n`).join(''); + const signedHeaders = sortedKeys.join(';'); + + // Canonical Request + const canonicalRequest = [ + method, canonicalUri, canonicalQuery, + canonicalHeaders, signedHeaders, payloadHash + ].join('\n'); + + // String to Sign + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + credentialScope, + await sha256Hex(canonicalRequest) + ].join('\n'); + + // 派生签名密钥 + let k = await hmac('AWS4' + secretKey, dateStamp); + k = await hmac(k, region); + k = await hmac(k, service); + k = await hmac(k, 'aws4_request'); + + // 最终签名 + const signature = bufToHex(await hmac(k, stringToSign)); + + const authorization = + `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${signature}`; + + return { authorization, amzDate }; + } + + // ---------- 扩展类 ---------- class S3Extension { constructor() { - // 在内存中保存凭据(仅用于演示,生产环境需注意安全) - this.credentials = {}; + this.host = ''; + this.port = 443; + this.isSSL = true; + this.accessKey = ''; + this.secretKey = ''; + this.region = 'us-east-1'; // 默认区域,可后续扩展块修改 } getInfo() { return { id: 's3client', name: 'S3 客户端', + color1: '#2e7d32', + color2: '#1b5e20', blocks: [ + // ========== 块 1:初始化 ========== { - opcode: 'setCredentials', + opcode: 'init', blockType: Scratch.BlockType.COMMAND, - text: '设置 S3 凭据 Key [AK] Secret [SK] 区域 [REGION]', + text: '初始化 S3 host [HOST] 端口 [PORT] SSL [SSL] AccessKey [AK] SecretKey [SK]', arguments: { - AK: { type: Scratch.ArgumentType.STRING, defaultValue: 'AKIA...' }, - SK: { type: Scratch.ArgumentType.STRING, defaultValue: 'secret...' }, - REGION: { type: Scratch.ArgumentType.STRING, defaultValue: 'us-east-1' } + HOST: { + type: Scratch.ArgumentType.STRING, + defaultValue: 's3.us-east-1.amazonaws.com' + }, + PORT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 443 + }, + SSL: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'true', + menu: 'booleanMenu' + }, + AK: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'AKIA...' + }, + SK: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'secret...' + } } }, + + // ========== 块 2:上传文件 ========== { - opcode: 'putObject', + opcode: 'uploadFile', blockType: Scratch.BlockType.COMMAND, - text: '上传桶 [BUCKET] 键 [KEY] 内容 [BODY]', + text: '上传文件 桶 [BUCKET] 本地路径 [SRC] 目标键 [DST]', arguments: { BUCKET: { type: Scratch.ArgumentType.STRING, defaultValue: 'my-bucket' }, - KEY: { type: Scratch.ArgumentType.STRING, defaultValue: 'hello.txt' }, - BODY: { type: Scratch.ArgumentType.STRING, defaultValue: 'Hello S3!' } + SRC: { type: Scratch.ArgumentType.STRING, defaultValue: 'local/file.txt' }, + DST: { type: Scratch.ArgumentType.STRING, defaultValue: 'remote/file.txt' } } }, + + // ========== 块 3:下载文件 ========== { - opcode: 'getObject', - blockType: Scratch.BlockType.REPORTER, - text: '下载桶 [BUCKET] 键 [KEY]', + opcode: 'downloadFile', + blockType: Scratch.BlockType.COMMAND, + text: '下载文件 桶 [BUCKET] 源键 [SRC] 本地路径 [DST]', arguments: { BUCKET: { type: Scratch.ArgumentType.STRING, defaultValue: 'my-bucket' }, - KEY: { type: Scratch.ArgumentType.STRING, defaultValue: 'hello.txt' } + SRC: { type: Scratch.ArgumentType.STRING, defaultValue: 'remote/file.txt' }, + DST: { type: Scratch.ArgumentType.STRING, defaultValue: 'local/file.txt' } } } - ] + ], + menus: { + booleanMenu: { + items: ['true', 'false'] + } + } }; } - setCredentials(args) { - this.credentials = { - accessKey: args.AK, - secretKey: args.SK, - region: args.REGION + // ---------- 块 1 实现 ---------- + init(args) { + this.host = String(args.HOST).trim(); + this.port = Number(args.PORT) || 443; + this.isSSL = String(args.SSL).toLowerCase() === 'true'; + this.accessKey = String(args.AK).trim(); + this.secretKey = String(args.SK).trim(); + + // 如果 host 里带 region,尝试自动解析,例如 s3.us-west-2.amazonaws.com + const m = this.host.match(/s3[.-]([a-z0-9-]+)\.amazonaws\.com/); + if (m) this.region = m[1]; + } + + // ---------- 构造 endpoint ---------- + _endpoint() { + const scheme = this.isSSL ? 'https' : 'http'; + const defaultPort = this.isSSL ? 443 : 80; + const portPart = this.port && this.port !== defaultPort ? `:${this.port}` : ''; + return `${scheme}://${this.host}${portPart}`; + } + + // ---------- 通用请求 ---------- + async _request(method, bucket, key, { body, headers = {} } = {}) { + if (!this.accessKey || !this.secretKey) { + throw new Error('请先调用“初始化 S3”块设置凭据'); + } + + // 路径风格:/bucket/key(兼容 MinIO、Ceph 等) + const path = '/' + uriEncode(bucket, false) + '/' + uriEncode(key, false); + + let payloadHash; + let bodyBytes = null; + if (body === undefined || body === null) { + payloadHash = await sha256Hex(''); + } else { + bodyBytes = body; // Uint8Array + payloadHash = await sha256Hex(bodyBytes); + } + + const urlObj = new URL(this._endpoint()); + const hostHeader = urlObj.port + ? `${urlObj.hostname}:${urlObj.port}` + : urlObj.hostname; + + const { authorization, amzDate } = await signRequest({ + method, + host: hostHeader, + path, + query: {}, + headers, + payloadHash, + region: this.region, + service: 's3', + accessKey: this.accessKey, + secretKey: this.secretKey + }); + + const finalHeaders = { + ...headers, + 'x-amz-date': amzDate, + 'x-amz-content-sha256': payloadHash, + 'Authorization': authorization }; + + const res = await fetch(`${this._endpoint()}${path}`, { + method, + headers: finalHeaders, + body: bodyBytes + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`S3 ${res.status}: ${text}`); + } + return res; } - // 这里接入上一轮实现的 SigV4 签名与 fetch 调用 - async putObject(args) { - const { accessKey, secretKey, region } = this.credentials; - // 1. 计算 payload hash(对 BODY 做 SHA256) - // 2. 构造 CanonicalRequest -> StringToSign -> Signature - // 3. fetch(`https://${args.BUCKET}.s3.${region}.amazonaws.com/${args.KEY}`, { method: 'PUT', ... }) - // 返回成功或抛出错误 + // ---------- 块 2 实现:上传 ---------- + async uploadFile(args) { + const bucket = String(args.BUCKET); + const src = String(args.SRC); + const dst = String(args.DST); + + // 读取本地文件(TurboWarp 非沙箱下可用 fetch 读取同源/允许的文件) + let data; + try { + const fileRes = await fetch(src); + if (!fileRes.ok) throw new Error(`读取本地文件失败: ${fileRes.status}`); + data = new Uint8Array(await fileRes.arrayBuffer()); + } catch (e) { + throw new Error(`无法读取本地文件 ${src}:${e.message}`); + } + + await this._request('PUT', bucket, dst, { + body: data, + headers: { 'content-type': 'application/octet-stream' } + }); } - async getObject(args) { - // 签名时 payload hash 为空字符串的 SHA256 - // fetch GET,返回 res.text() 或 arrayBuffer + // ---------- 块 3 实现:下载 ---------- + async downloadFile(args) { + const bucket = String(args.BUCKET); + const src = String(args.SRC); + const dst = String(args.DST); + + const res = await this._request('GET', bucket, src); + const buf = new Uint8Array(await res.arrayBuffer()); + + // 触发浏览器下载 + const blob = new Blob([buf], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + // 用目标路径的最后一段作为文件名 + a.download = dst.split('/').pop() || 'download.bin'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); } } Scratch.extensions.register(new S3Extension()); -})(Scratch); +})(Scratch); \ No newline at end of file