reimplement

This commit is contained in:
database-mysql committed 2026-10-04 04:47:58 +08:00
1 parent 155e5dc086
commit 735d888d96
1 file changed
+272 -34
+272 -34
View File
@@ -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);