补齐模板库模板源、空白模板与确定性打包
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m42s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m58s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m24s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Frontend tests (pull_request) Failing after 4m47s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m28s
Project CI / Native shell tests (pull_request) Successful in 8m51s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m42s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m58s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m24s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Frontend tests (pull_request) Failing after 4m47s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m28s
Project CI / Native shell tests (pull_request) Successful in 8m51s
- 新增仓库模板源 apps/ai-game-creator-shell/template-library/:空白网页、空白二维画布、空白三维场景三个空白模板,以及 Phaser 2D、Three.js 3D 两个起步工程模板 - 发布脚本改为按 project/ 现场打包确定性 zip(条目排序 + 固定时间戳),支持 svg 封面、--prune 清理旧对象与 --index-out - 模板库新增真连检查:读取线上清单、下载并安装线上模板包后校验文件落盘 - 刷新线上清单 fixture,并同步技术方案与实施计划的封面格式、模板源与发布方式
This commit is contained in:
@@ -1,36 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 发布 AGC 模板库到 OSS。
|
||||
* 打包并发布 AGC 模板库到 OSS。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run]
|
||||
* node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run] [--prune]
|
||||
* [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]
|
||||
* [--index-out <file>]
|
||||
*
|
||||
* 源目录结构(模板正文是 zip,zip 根 == AGC 项目根,例如 game/index.html):
|
||||
* 源目录结构(仓库内为 `apps/ai-game-creator-shell/template-library/`):
|
||||
* <source>/v1/<templateId>/meta.json 模板元数据(title/summary/tags/runtime/engine/…)
|
||||
* <source>/v1/<templateId>/template.zip 模板包
|
||||
* <source>/v1/<templateId>/cover.png 封面图
|
||||
* <source>/v1/<templateId>/cover.(png|jpg|jpeg|webp|svg) 封面图
|
||||
* <source>/v1/<templateId>/project/** 模板正文(就是解压后的项目根内容)
|
||||
*
|
||||
* 脚本会按包内容生成 `v1/<templateId>/template.json` 与库清单 `index.json`,
|
||||
* 然后上传 zip、封面、模板元数据与清单。上传前会完成全部校验,任何一项不合法都不发请求。
|
||||
* 脚本按 `project/` 现场打包 `template.zip`(zip 根 == AGC 项目根),再上传
|
||||
* `v1/<id>/{template.zip,cover.*,template.json}` 与库清单 `index.json`;
|
||||
* `--prune` 会删除该模板前缀下本次没有产出的旧对象(例如换了封面扩展名)。
|
||||
* 上传前完成全部校验,任何一项不合法都不发请求。
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
|
||||
const SCHEMA_VERSION = 'agc-template-library.v1';
|
||||
const TEMPLATE_SCHEMA_VERSION = 'agc-template.v1';
|
||||
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
||||
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
|
||||
const RUNTIMES = new Set(['html', 'unity', 'godot', 'cocos']);
|
||||
const COVER_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const COVER_CONTENT_TYPES = new Map([
|
||||
['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.webp', 'image/webp'],
|
||||
['.svg', 'image/svg+xml'],
|
||||
]);
|
||||
|
||||
function usage() {
|
||||
console.log(
|
||||
[
|
||||
'用法: node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run]',
|
||||
'用法: node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run] [--prune]',
|
||||
' [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]',
|
||||
' [--index-out <file>]',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
@@ -43,7 +54,9 @@ function parseArgs(argv) {
|
||||
process.env.AGC_TEMPLATE_LIBRARY_ENDPOINT?.trim() ||
|
||||
'oss-rg-china-mainland.aliyuncs.com',
|
||||
prefix: 'templates',
|
||||
indexOut: '',
|
||||
dryRun: false,
|
||||
prune: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
@@ -51,7 +64,9 @@ function parseArgs(argv) {
|
||||
else if (value === '--bucket') args.bucket = argv[(index += 1)] ?? '';
|
||||
else if (value === '--endpoint') args.endpoint = argv[(index += 1)] ?? '';
|
||||
else if (value === '--prefix') args.prefix = argv[(index += 1)] ?? '';
|
||||
else if (value === '--index-out') args.indexOut = argv[(index += 1)] ?? '';
|
||||
else if (value === '--dry-run') args.dryRun = true;
|
||||
else if (value === '--prune') args.prune = true;
|
||||
else if (value === '--help' || value === '-h') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
@@ -97,25 +112,102 @@ function loadAccessKeys() {
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
function walkFiles(directory, base = directory) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
|
||||
(left, right) => left.name.localeCompare(right.name),
|
||||
)) {
|
||||
const full = join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkFiles(full, base));
|
||||
else {
|
||||
const bytes = readFileSync(full);
|
||||
files.push({
|
||||
path: full
|
||||
.slice(base.length + 1)
|
||||
.split('\\')
|
||||
.join('/'),
|
||||
sizeBytes: bytes.length,
|
||||
sha256: sha256(bytes),
|
||||
});
|
||||
const CRC32_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成确定性的 ZIP:条目按路径排序、固定 DOS 时间戳、UTF-8 名称位标记。
|
||||
* 同一份 `project/` 内容重复打包得到相同摘要,便于比对发布结果。
|
||||
*/
|
||||
function buildZip(entries) {
|
||||
const chunks = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
for (const entry of entries) {
|
||||
const nameBytes = Buffer.from(entry.path, 'utf8');
|
||||
const content = entry.bytes;
|
||||
const compressed = deflateRawSync(content, { level: 9 });
|
||||
const checksum = crc32(content);
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt16LE(0, 10);
|
||||
local.writeUInt16LE(0x0021, 12);
|
||||
local.writeUInt32LE(checksum, 14);
|
||||
local.writeUInt32LE(compressed.length, 18);
|
||||
local.writeUInt32LE(content.length, 22);
|
||||
local.writeUInt16LE(nameBytes.length, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
chunks.push(local, nameBytes, compressed);
|
||||
const directory = Buffer.alloc(46);
|
||||
directory.writeUInt32LE(0x02014b50, 0);
|
||||
directory.writeUInt16LE(20, 4);
|
||||
directory.writeUInt16LE(20, 6);
|
||||
directory.writeUInt16LE(0x0800, 8);
|
||||
directory.writeUInt16LE(8, 10);
|
||||
directory.writeUInt16LE(0, 12);
|
||||
directory.writeUInt16LE(0x0021, 14);
|
||||
directory.writeUInt32LE(checksum, 16);
|
||||
directory.writeUInt32LE(compressed.length, 20);
|
||||
directory.writeUInt32LE(content.length, 24);
|
||||
directory.writeUInt16LE(nameBytes.length, 28);
|
||||
directory.writeUInt16LE(0, 30);
|
||||
directory.writeUInt16LE(0, 32);
|
||||
directory.writeUInt16LE(0, 34);
|
||||
directory.writeUInt16LE(0, 36);
|
||||
directory.writeUInt32LE(0, 38);
|
||||
directory.writeUInt32LE(offset, 42);
|
||||
central.push(directory, nameBytes);
|
||||
offset += local.length + nameBytes.length + compressed.length;
|
||||
}
|
||||
const centralBytes = Buffer.concat(central);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(0, 4);
|
||||
end.writeUInt16LE(0, 6);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(centralBytes.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
end.writeUInt16LE(0, 20);
|
||||
return Buffer.concat([...chunks, centralBytes, end]);
|
||||
}
|
||||
|
||||
function readProjectFiles(projectRoot) {
|
||||
const files = [];
|
||||
const walk = (directory, prefix) => {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
|
||||
(left, right) => left.name.localeCompare(right.name),
|
||||
)) {
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const full = join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(full, relative);
|
||||
else if (entry.isFile())
|
||||
files.push({ path: relative, bytes: readFileSync(full) });
|
||||
else throw new Error(`模板正文包含不支持的条目:${full}`);
|
||||
}
|
||||
};
|
||||
walk(projectRoot, '');
|
||||
if (files.length === 0) throw new Error(`模板正文为空:${projectRoot}`);
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -123,10 +215,12 @@ function readMeta(templateRoot, templateId) {
|
||||
const metaPath = join(templateRoot, 'meta.json');
|
||||
if (!existsSync(metaPath)) throw new Error(`${templateId} 缺少 meta.json`);
|
||||
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
||||
if (!TEMPLATE_ID_PATTERN.test(meta.id ?? ''))
|
||||
if (!TEMPLATE_ID_PATTERN.test(meta.id ?? '')) {
|
||||
throw new Error(`${templateId} 的 meta.id 非法`);
|
||||
if (meta.id !== templateId)
|
||||
}
|
||||
if (meta.id !== templateId) {
|
||||
throw new Error(`${templateId} 目录名与 meta.id 不一致:${meta.id}`);
|
||||
}
|
||||
if (typeof meta.title !== 'string' || !meta.title.trim()) {
|
||||
throw new Error(`${templateId} 缺少 title`);
|
||||
}
|
||||
@@ -140,12 +234,14 @@ function readMeta(templateRoot, templateId) {
|
||||
}
|
||||
if (
|
||||
!Array.isArray(meta.tags) ||
|
||||
meta.tags.length === 0 ||
|
||||
meta.tags.some((tag) => typeof tag !== 'string' || !tag.trim())
|
||||
) {
|
||||
throw new Error(`${templateId} 的 tags 必须是非空字符串数组`);
|
||||
}
|
||||
if (
|
||||
typeof meta.entry !== 'string' ||
|
||||
!meta.entry.trim() ||
|
||||
meta.entry.includes('..') ||
|
||||
meta.entry.startsWith('/')
|
||||
) {
|
||||
@@ -167,25 +263,23 @@ function buildLibrary(source, prefix) {
|
||||
const updatedAt = new Date().toISOString().replace(/\.\d{3}Z$/u, 'Z');
|
||||
const templates = [];
|
||||
const objects = [];
|
||||
const managedPrefixes = [];
|
||||
|
||||
for (const templateId of templateIds) {
|
||||
const templateRoot = join(versionRoot, templateId);
|
||||
const meta = readMeta(templateRoot, templateId);
|
||||
const zipPath = join(templateRoot, 'template.zip');
|
||||
if (!existsSync(zipPath))
|
||||
throw new Error(`${templateId} 缺少 template.zip`);
|
||||
const zipBytes = readFileSync(zipPath);
|
||||
const coverEntries = readdirSync(templateRoot).filter((name) =>
|
||||
COVER_EXTENSIONS.has(name.slice(name.lastIndexOf('.')).toLowerCase()),
|
||||
const projectFiles = readProjectFiles(join(templateRoot, 'project'));
|
||||
const zipBytes = buildZip(projectFiles);
|
||||
const coverNames = readdirSync(templateRoot).filter((name) =>
|
||||
COVER_CONTENT_TYPES.has(name.slice(name.lastIndexOf('.')).toLowerCase()),
|
||||
);
|
||||
if (coverEntries.length !== 1) {
|
||||
throw new Error(`${templateId} 必须且只能有一张封面图(png/jpg/webp)`);
|
||||
if (coverNames.length !== 1) {
|
||||
throw new Error(
|
||||
`${templateId} 必须且只能有一张封面图(png/jpg/webp/svg)`,
|
||||
);
|
||||
}
|
||||
const coverName = coverEntries[0];
|
||||
const coverName = coverNames[0];
|
||||
const coverBytes = readFileSync(join(templateRoot, coverName));
|
||||
const projectFiles = walkFiles(join(templateRoot, 'project'));
|
||||
if (projectFiles.length === 0)
|
||||
throw new Error(`${templateId} 的 project/ 为空`);
|
||||
|
||||
const zipKey = `${prefix}/v1/${templateId}/template.zip`;
|
||||
const coverKey = `${prefix}/v1/${templateId}/${coverName}`;
|
||||
@@ -214,7 +308,11 @@ function buildLibrary(source, prefix) {
|
||||
height: Number.isInteger(meta.coverHeight) ? meta.coverHeight : 0,
|
||||
sha256: sha256(coverBytes),
|
||||
},
|
||||
files: projectFiles,
|
||||
files: projectFiles.map((file) => ({
|
||||
path: file.path,
|
||||
sizeBytes: file.bytes.length,
|
||||
sha256: sha256(file.bytes),
|
||||
})),
|
||||
};
|
||||
|
||||
templates.push({
|
||||
@@ -238,16 +336,15 @@ function buildLibrary(source, prefix) {
|
||||
metadataKey,
|
||||
});
|
||||
|
||||
managedPrefixes.push(`${prefix}/v1/${templateId}/`);
|
||||
objects.push(
|
||||
{ key: zipKey, body: zipBytes, contentType: 'application/zip' },
|
||||
{
|
||||
key: coverKey,
|
||||
body: coverBytes,
|
||||
contentType: coverName.endsWith('.webp')
|
||||
? 'image/webp'
|
||||
: coverName.endsWith('.png')
|
||||
? 'image/png'
|
||||
: 'image/jpeg',
|
||||
contentType: COVER_CONTENT_TYPES.get(
|
||||
coverName.slice(coverName.lastIndexOf('.')).toLowerCase(),
|
||||
),
|
||||
},
|
||||
{
|
||||
key: metadataKey,
|
||||
@@ -272,37 +369,72 @@ function buildLibrary(source, prefix) {
|
||||
body: Buffer.from(`${JSON.stringify(indexJson, null, 2)}\n`, 'utf8'),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
writeFileSync(
|
||||
join(source, 'index.json'),
|
||||
`${JSON.stringify(indexJson, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
return { objects, indexJson };
|
||||
return { objects, indexJson, managedPrefixes };
|
||||
}
|
||||
|
||||
function createClient({ bucket, endpoint, accessKeyId, accessKeySecret }) {
|
||||
async function call(
|
||||
method,
|
||||
key,
|
||||
{ body = Buffer.alloc(0), contentType = '' } = {},
|
||||
) {
|
||||
function authorize(method, resourcePath, contentType) {
|
||||
const date = new Date().toUTCString();
|
||||
const stringToSign = `${method}\n\n${contentType}\n${date}\n/${bucket}/${key}`;
|
||||
const stringToSign = `${method}\n\n${contentType}\n${date}\n${resourcePath}`;
|
||||
const signature = createHmac('sha1', accessKeySecret)
|
||||
.update(stringToSign, 'utf8')
|
||||
.digest('base64');
|
||||
const response = await fetch(`https://${bucket}.${endpoint}/${key}`, {
|
||||
method,
|
||||
return { date, authorization: `OSS ${accessKeyId}:${signature}` };
|
||||
}
|
||||
|
||||
async function put(key, body, contentType) {
|
||||
const { date, authorization } = authorize(
|
||||
'PUT',
|
||||
`/${bucket}/${key}`,
|
||||
contentType,
|
||||
);
|
||||
return fetch(`https://${bucket}.${endpoint}/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Date: date,
|
||||
Authorization: `OSS ${accessKeyId}:${signature}`,
|
||||
Authorization: authorization,
|
||||
...(contentType ? { 'Content-Type': contentType } : {}),
|
||||
},
|
||||
...(method === 'PUT' ? { body } : {}),
|
||||
body,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
return { call };
|
||||
|
||||
async function get(key) {
|
||||
const { date, authorization } = authorize('GET', `/${bucket}/${key}`, '');
|
||||
return fetch(`https://${bucket}.${endpoint}/${key}`, {
|
||||
headers: { Date: date, Authorization: authorization },
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(key) {
|
||||
const { date, authorization } = authorize(
|
||||
'DELETE',
|
||||
`/${bucket}/${key}`,
|
||||
'',
|
||||
);
|
||||
return fetch(`https://${bucket}.${endpoint}/${key}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Date: date, Authorization: authorization },
|
||||
});
|
||||
}
|
||||
|
||||
async function listKeys(prefix) {
|
||||
const { date, authorization } = authorize('GET', `/${bucket}/`, '');
|
||||
const response = await fetch(
|
||||
`https://${bucket}.${endpoint}/?prefix=${encodeURIComponent(prefix)}&max-keys=1000`,
|
||||
{ headers: { Date: date, Authorization: authorization } },
|
||||
);
|
||||
const body = await response.text();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`列举对象失败:HTTP ${response.status} ${body.slice(0, 200)}`,
|
||||
);
|
||||
return [...body.matchAll(/<Key>([\s\S]*?)<\/Key>/gu)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
}
|
||||
|
||||
return { put, get, remove, listKeys };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -312,7 +444,17 @@ async function main() {
|
||||
throw new Error('必须提供 --source');
|
||||
}
|
||||
const source = resolve(args.source);
|
||||
const { objects, indexJson } = buildLibrary(source, args.prefix);
|
||||
const { objects, indexJson, managedPrefixes } = buildLibrary(
|
||||
source,
|
||||
args.prefix,
|
||||
);
|
||||
if (args.indexOut) {
|
||||
writeFileSync(
|
||||
resolve(args.indexOut),
|
||||
`${JSON.stringify(indexJson, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`模板库:${indexJson.templates.length} 个模板 -> oss://${args.bucket}/${args.prefix}/`,
|
||||
);
|
||||
@@ -327,10 +469,15 @@ async function main() {
|
||||
console.log(` PUT ${object.key} (${object.body.length} B)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = loadAccessKeys();
|
||||
const client = createClient({ ...args, ...credentials });
|
||||
for (const object of objects) {
|
||||
const response = await client.call('PUT', object.key, object);
|
||||
const response = await client.put(
|
||||
object.key,
|
||||
object.body,
|
||||
object.contentType,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`上传失败 ${object.key}:HTTP ${response.status} ${await response.text()}`,
|
||||
@@ -340,10 +487,23 @@ async function main() {
|
||||
`PUT ${object.key} (${object.body.length} B) -> ${response.status}`,
|
||||
);
|
||||
}
|
||||
const verify = await client.call('GET', `${args.prefix}/index.json`);
|
||||
|
||||
if (args.prune) {
|
||||
const uploaded = new Set(objects.map((object) => object.key));
|
||||
for (const prefix of managedPrefixes) {
|
||||
for (const key of await client.listKeys(prefix)) {
|
||||
if (uploaded.has(key) || key === prefix) continue;
|
||||
const response = await client.remove(key);
|
||||
console.log(`DELETE ${key} -> ${response.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const verify = await client.get(`${args.prefix}/index.json`);
|
||||
if (!verify.ok) throw new Error(`回读清单失败:HTTP ${verify.status}`);
|
||||
const liveIndex = JSON.parse(await verify.text());
|
||||
for (const template of liveIndex.templates) {
|
||||
const zipResponse = await client.call('GET', template.zipKey);
|
||||
const zipResponse = await client.get(template.zipKey);
|
||||
const zipBytes = Buffer.from(await zipResponse.arrayBuffer());
|
||||
const digest = sha256(zipBytes);
|
||||
if (digest !== template.zipSha256) {
|
||||
|
||||
Reference in New Issue
Block a user