Compare commits
3
Commits
6dc7105b4c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
476946a85d | ||
|
|
735d888d96 | ||
|
|
155e5dc086 |
No files matched your search
@@ -1,2 +1,3 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
./.gitignore
|
./.gitignore
|
||||||
|
./TurboWarp_S3.iml
|
||||||
+271
-33
@@ -1,75 +1,313 @@
|
|||||||
(function (Scratch) {
|
(function (Scratch) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// 非沙箱守卫:确保扩展有权限发起网络请求
|
// 非沙箱守卫
|
||||||
if (!Scratch.extensions.unsandboxed) {
|
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 {
|
class S3Extension {
|
||||||
constructor() {
|
constructor() {
|
||||||
// 在内存中保存凭据(仅用于演示,生产环境需注意安全)
|
this.host = '';
|
||||||
this.credentials = {};
|
this.port = 443;
|
||||||
|
this.isSSL = true;
|
||||||
|
this.accessKey = '';
|
||||||
|
this.secretKey = '';
|
||||||
|
this.region = 'us-east-1'; // 默认区域,可后续扩展块修改
|
||||||
}
|
}
|
||||||
|
|
||||||
getInfo() {
|
getInfo() {
|
||||||
return {
|
return {
|
||||||
id: 's3client',
|
id: 's3client',
|
||||||
name: 'S3 客户端',
|
name: 'S3 客户端',
|
||||||
|
color1: '#2e7d32',
|
||||||
|
color2: '#1b5e20',
|
||||||
blocks: [
|
blocks: [
|
||||||
|
// ========== 块 1:初始化 ==========
|
||||||
{
|
{
|
||||||
opcode: 'setCredentials',
|
opcode: 'init',
|
||||||
blockType: Scratch.BlockType.COMMAND,
|
blockType: Scratch.BlockType.COMMAND,
|
||||||
text: '设置 S3 凭据 Key [AK] Secret [SK] 区域 [REGION]',
|
text: '初始化 S3 host [HOST] 端口 [PORT] SSL [SSL] AccessKey [AK] SecretKey [SK]',
|
||||||
arguments: {
|
arguments: {
|
||||||
AK: { type: Scratch.ArgumentType.STRING, defaultValue: 'AKIA...' },
|
HOST: {
|
||||||
SK: { type: Scratch.ArgumentType.STRING, defaultValue: 'secret...' },
|
type: Scratch.ArgumentType.STRING,
|
||||||
REGION: { type: Scratch.ArgumentType.STRING, defaultValue: 'us-east-1' }
|
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,
|
blockType: Scratch.BlockType.COMMAND,
|
||||||
text: '上传桶 [BUCKET] 键 [KEY] 内容 [BODY]',
|
text: '上传文件 桶 [BUCKET] 本地路径 [SRC] 目标键 [DST]',
|
||||||
arguments: {
|
arguments: {
|
||||||
BUCKET: { type: Scratch.ArgumentType.STRING, defaultValue: 'my-bucket' },
|
BUCKET: { type: Scratch.ArgumentType.STRING, defaultValue: 'my-bucket' },
|
||||||
KEY: { type: Scratch.ArgumentType.STRING, defaultValue: 'hello.txt' },
|
SRC: { type: Scratch.ArgumentType.STRING, defaultValue: 'local/file.txt' },
|
||||||
BODY: { type: Scratch.ArgumentType.STRING, defaultValue: 'Hello S3!' }
|
DST: { type: Scratch.ArgumentType.STRING, defaultValue: 'remote/file.txt' }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========== 块 3:下载文件 ==========
|
||||||
{
|
{
|
||||||
opcode: 'getObject',
|
opcode: 'downloadFile',
|
||||||
blockType: Scratch.BlockType.REPORTER,
|
blockType: Scratch.BlockType.COMMAND,
|
||||||
text: '下载桶 [BUCKET] 键 [KEY]',
|
text: '下载文件 桶 [BUCKET] 源键 [SRC] 本地路径 [DST]',
|
||||||
arguments: {
|
arguments: {
|
||||||
BUCKET: { type: Scratch.ArgumentType.STRING, defaultValue: 'my-bucket' },
|
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) {
|
// ---------- 块 1 实现 ----------
|
||||||
this.credentials = {
|
init(args) {
|
||||||
accessKey: args.AK,
|
this.host = String(args.HOST).trim();
|
||||||
secretKey: args.SK,
|
this.port = Number(args.PORT) || 443;
|
||||||
region: args.REGION
|
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 调用
|
// ---------- 块 2 实现:上传 ----------
|
||||||
async putObject(args) {
|
async uploadFile(args) {
|
||||||
const { accessKey, secretKey, region } = this.credentials;
|
const bucket = String(args.BUCKET);
|
||||||
// 1. 计算 payload hash(对 BODY 做 SHA256)
|
const src = String(args.SRC);
|
||||||
// 2. 构造 CanonicalRequest -> StringToSign -> Signature
|
const dst = String(args.DST);
|
||||||
// 3. fetch(`https://${args.BUCKET}.s3.${region}.amazonaws.com/${args.KEY}`, { method: 'PUT', ... })
|
|
||||||
// 返回成功或抛出错误
|
// 读取本地文件(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) {
|
// ---------- 块 3 实现:下载 ----------
|
||||||
// 签名时 payload hash 为空字符串的 SHA256
|
async downloadFile(args) {
|
||||||
// fetch GET,返回 res.text() 或 arrayBuffer
|
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