Compare commits
3
Commits
6dc7105b4c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
476946a85d | ||
|
|
735d888d96 | ||
|
|
155e5dc086 |
No files matched your search
@@ -1,2 +1,3 @@
|
||||
/.idea/
|
||||
./.gitignore
|
||||
./TurboWarp_S3.iml
|
||||
+271
-33
@@ -1,75 +1,313 @@
|
||||
(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}`);
|
||||
}
|
||||
|
||||
async getObject(args) {
|
||||
// 签名时 payload hash 为空字符串的 SHA256
|
||||
// fetch GET,返回 res.text() 或 arrayBuffer
|
||||
await this._request('PUT', bucket, dst, {
|
||||
body: data,
|
||||
headers: { 'content-type': 'application/octet-stream' }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 块 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user