refactor(expo-updates): 重构manifest构建逻辑,支持动态获取metadata和真实hash计算
This commit is contained in:
9
package-lock.json
generated
9
package-lock.json
generated
@@ -31,6 +31,7 @@
|
|||||||
"cos-nodejs-sdk-v5": "^2.14.7",
|
"cos-nodejs-sdk-v5": "^2.14.7",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -7376,14 +7377,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.12"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"cos-nodejs-sdk-v5": "^2.14.7",
|
"cos-nodejs-sdk-v5": "^2.14.7",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiHeader, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiHeader, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import * as FormData from 'form-data';
|
||||||
import { ExpoUpdatesService } from './expo-updates.service';
|
import { ExpoUpdatesService } from './expo-updates.service';
|
||||||
|
import { logger } from 'src/common/logger/winston.config';
|
||||||
|
|
||||||
@ApiTags('Expo Updates')
|
@ApiTags('Expo Updates')
|
||||||
@Controller('expo-updates')
|
@Controller('expo-updates')
|
||||||
@@ -48,52 +50,54 @@ export class ExpoUpdatesController {
|
|||||||
throw new BadRequestException('No runtimeVersion provided.');
|
throw new BadRequestException('No runtimeVersion provided.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = this.expoUpdatesService.buildManifest(platform as 'ios' | 'android', runtimeVersion);
|
logger.info(`Getting manifest for platform: ${platform}, runtimeVersion: ${runtimeVersion}`);
|
||||||
|
const manifest = await this.expoUpdatesService.buildManifest(platform as 'ios' | 'android', runtimeVersion);
|
||||||
|
|
||||||
|
logger.info(`Manifest: ${JSON.stringify(manifest)}`);
|
||||||
|
|
||||||
// 已是最新版本
|
// 已是最新版本
|
||||||
if (currentUpdateId === manifest.id && protocolVersion === 1) {
|
if (currentUpdateId === manifest.id && protocolVersion === 1) {
|
||||||
return this.sendNoUpdateAvailable(res);
|
return this.sendNoUpdateAvailable(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建 multipart 响应
|
// 使用 form-data 构建正确的 multipart 响应
|
||||||
const boundary = `----ExpoUpdates${Date.now()}`;
|
const form = new FormData();
|
||||||
const parts = [
|
|
||||||
`--${boundary}`,
|
form.append('manifest', JSON.stringify(manifest), {
|
||||||
'content-disposition: form-data; name="manifest"',
|
contentType: 'application/json',
|
||||||
'content-type: application/json; charset=utf-8',
|
header: {
|
||||||
'',
|
'content-type': 'application/json; charset=utf-8',
|
||||||
JSON.stringify(manifest),
|
},
|
||||||
`--${boundary}`,
|
});
|
||||||
'content-disposition: form-data; name="extensions"',
|
|
||||||
'content-type: application/json',
|
|
||||||
'',
|
|
||||||
JSON.stringify({ assetRequestHeaders: {} }),
|
|
||||||
`--${boundary}--`,
|
|
||||||
];
|
|
||||||
|
|
||||||
|
form.append('extensions', JSON.stringify({ assetRequestHeaders: {} }), {
|
||||||
|
contentType: 'application/json',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
res.setHeader('expo-protocol-version', protocolVersion);
|
res.setHeader('expo-protocol-version', protocolVersion);
|
||||||
res.setHeader('expo-sfv-version', 0);
|
res.setHeader('expo-sfv-version', 0);
|
||||||
res.setHeader('cache-control', 'private, max-age=0');
|
res.setHeader('cache-control', 'private, max-age=0');
|
||||||
res.setHeader('content-type', `multipart/mixed; boundary=${boundary}`);
|
res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
|
||||||
res.send(parts.join('\r\n'));
|
res.send(form.getBuffer());
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendNoUpdateAvailable(res: Response) {
|
private sendNoUpdateAvailable(res: Response) {
|
||||||
const boundary = `----ExpoUpdates${Date.now()}`;
|
const form = new FormData();
|
||||||
const directive = this.expoUpdatesService.createNoUpdateAvailableDirective();
|
const directive = this.expoUpdatesService.createNoUpdateAvailableDirective();
|
||||||
const parts = [
|
|
||||||
`--${boundary}`,
|
|
||||||
'content-disposition: form-data; name="directive"',
|
|
||||||
'content-type: application/json; charset=utf-8',
|
|
||||||
'',
|
|
||||||
JSON.stringify(directive),
|
|
||||||
`--${boundary}--`,
|
|
||||||
];
|
|
||||||
|
|
||||||
|
form.append('directive', JSON.stringify(directive), {
|
||||||
|
contentType: 'application/json',
|
||||||
|
header: {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
res.setHeader('expo-protocol-version', 1);
|
res.setHeader('expo-protocol-version', 1);
|
||||||
res.setHeader('expo-sfv-version', 0);
|
res.setHeader('expo-sfv-version', 0);
|
||||||
res.setHeader('cache-control', 'private, max-age=0');
|
res.setHeader('cache-control', 'private, max-age=0');
|
||||||
res.setHeader('content-type', `multipart/mixed; boundary=${boundary}`);
|
res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
|
||||||
res.send(parts.join('\r\n'));
|
res.send(form.getBuffer());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { logger } from 'src/common/logger/winston.config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
export interface AssetMetadata {
|
export interface AssetMetadata {
|
||||||
hash: string;
|
hash: string;
|
||||||
@@ -25,61 +28,98 @@ export interface NoUpdateAvailableDirective {
|
|||||||
type: 'noUpdateAvailable';
|
type: 'noUpdateAvailable';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MetadataFileAsset {
|
||||||
|
path: string;
|
||||||
|
ext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataFile {
|
||||||
|
version: number;
|
||||||
|
bundler: string;
|
||||||
|
fileMetadata: {
|
||||||
|
ios?: {
|
||||||
|
bundle: string;
|
||||||
|
assets: MetadataFileAsset[];
|
||||||
|
};
|
||||||
|
android?: {
|
||||||
|
bundle: string;
|
||||||
|
assets: MetadataFileAsset[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存 metadata 数据
|
||||||
|
interface MetadataCache {
|
||||||
|
data: MetadataFile;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存 hash 数据
|
||||||
|
interface HashCache {
|
||||||
|
hash: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpoUpdatesService {
|
export class ExpoUpdatesService {
|
||||||
|
private metadataCache: Map<string, MetadataCache> = new Map();
|
||||||
|
private hashCache: Map<string, HashCache> = new Map();
|
||||||
|
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {}
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从环境变量构建 manifest
|
* 从环境变量构建 manifest
|
||||||
*
|
*
|
||||||
* 环境变量配置:
|
* 环境变量配置:
|
||||||
* - EXPO_UPDATE_ID: 更新ID
|
* - EXPO_UPDATE_ID: 更新ID(可选)
|
||||||
* - EXPO_RUNTIME_VERSION: 运行时版本
|
* - EXPO_RUNTIME_VERSION: 运行时版本
|
||||||
* - EXPO_CREATED_AT: 创建时间
|
* - EXPO_IOS_METADATA_URL: iOS metadata.json URL
|
||||||
* - EXPO_IOS_BUNDLE_URL: iOS bundle URL
|
* - EXPO_ANDROID_METADATA_URL: Android metadata.json URL
|
||||||
* - EXPO_IOS_BUNDLE_HASH: iOS bundle hash
|
|
||||||
* - EXPO_ANDROID_BUNDLE_URL: Android bundle URL
|
|
||||||
* - EXPO_ANDROID_BUNDLE_HASH: Android bundle hash
|
|
||||||
* - EXPO_ASSETS: JSON格式的资源数组(可选)
|
|
||||||
*/
|
*/
|
||||||
buildManifest(platform: 'ios' | 'android', runtimeVersion: string): UpdateManifest {
|
async buildManifest(platform: 'ios' | 'android', runtimeVersion: string): Promise<UpdateManifest> {
|
||||||
const configRuntimeVersion = this.configService.get<string>('EXPO_RUNTIME_VERSION');
|
const configRuntimeVersion = this.configService.get<string>('EXPO_RUNTIME_VERSION');
|
||||||
|
|
||||||
|
logger.info(`buildManifest: configRuntimeVersion=${configRuntimeVersion}, runtimeVersion=${runtimeVersion}`);
|
||||||
|
|
||||||
// 检查运行时版本是否匹配
|
// 检查运行时版本是否匹配
|
||||||
if (configRuntimeVersion && configRuntimeVersion !== runtimeVersion) {
|
if (configRuntimeVersion && configRuntimeVersion !== runtimeVersion) {
|
||||||
throw new BadRequestException(`No update available for runtime version: ${runtimeVersion}`);
|
throw new BadRequestException(`No update available for runtime version: ${runtimeVersion}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundleUrl = platform === 'ios'
|
const metadataUrl = platform === 'ios'
|
||||||
? this.configService.get<string>('EXPO_IOS_BUNDLE_URL')
|
? this.configService.get<string>('EXPO_IOS_METADATA_URL')
|
||||||
: this.configService.get<string>('EXPO_ANDROID_BUNDLE_URL');
|
: this.configService.get<string>('EXPO_ANDROID_METADATA_URL');
|
||||||
|
|
||||||
const bundleHash = platform === 'ios'
|
if (!metadataUrl) {
|
||||||
? this.configService.get<string>('EXPO_IOS_BUNDLE_HASH')
|
throw new BadRequestException(`No metadata URL configured for platform: ${platform}`);
|
||||||
: this.configService.get<string>('EXPO_ANDROID_BUNDLE_HASH');
|
|
||||||
|
|
||||||
if (!bundleUrl || !bundleHash) {
|
|
||||||
throw new BadRequestException(`No update configured for platform: ${platform}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析资源配置
|
// 获取 metadata.json 内容
|
||||||
const assetsJson = this.configService.get<string>('EXPO_ASSETS');
|
const metadata = await this.fetchMetadata(metadataUrl);
|
||||||
let assets: AssetMetadata[] = [];
|
const platformMetadata = metadata.fileMetadata[platform];
|
||||||
if (assetsJson) {
|
|
||||||
try {
|
if (!platformMetadata) {
|
||||||
assets = JSON.parse(assetsJson);
|
throw new BadRequestException(`No ${platform} metadata found in metadata.json`);
|
||||||
} catch {
|
|
||||||
assets = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算基础 URL(metadata.json 所在目录)
|
||||||
|
const baseUrl = metadataUrl.substring(0, metadataUrl.lastIndexOf('/') + 1);
|
||||||
|
|
||||||
|
// 构建 bundle URL 并计算真实 hash
|
||||||
|
const bundleUrl = baseUrl + platformMetadata.bundle;
|
||||||
|
const bundleHash = await this.calculateFileHash(bundleUrl);
|
||||||
|
|
||||||
|
// 构建 assets(需要计算每个文件的真实 hash)
|
||||||
|
const assets = await this.buildAssetsWithHash(platformMetadata.assets, baseUrl);
|
||||||
|
|
||||||
// ID 基于 bundle hash 生成,确保内容不变时 ID 固定
|
// ID 基于 bundle hash 生成,确保内容不变时 ID 固定
|
||||||
const updateId = this.configService.get<string>('EXPO_UPDATE_ID')
|
const updateId = this.configService.get<string>('EXPO_UPDATE_ID')
|
||||||
|| this.hashToUUID(bundleHash);
|
|| this.convertSHA256HashToUUID(bundleHash);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: updateId,
|
id: updateId,
|
||||||
createdAt: this.configService.get<string>('EXPO_CREATED_AT') || new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
runtimeVersion: configRuntimeVersion || runtimeVersion,
|
runtimeVersion: configRuntimeVersion || runtimeVersion,
|
||||||
launchAsset: {
|
launchAsset: {
|
||||||
hash: bundleHash,
|
hash: bundleHash,
|
||||||
@@ -93,16 +133,130 @@ export class ExpoUpdatesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 metadata.json 内容(带缓存)
|
||||||
|
*/
|
||||||
|
private async fetchMetadata(url: string): Promise<MetadataFile> {
|
||||||
|
const cached = this.metadataCache.get(url);
|
||||||
|
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||||
|
logger.info(`Using cached metadata for ${url}`);
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Fetching metadata from ${url}`);
|
||||||
|
try {
|
||||||
|
const response = await axios.get<MetadataFile>(url, { timeout: 10000 });
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// 缓存数据
|
||||||
|
this.metadataCache.set(url, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to fetch metadata: ${error.message}`);
|
||||||
|
throw new BadRequestException(`Failed to fetch metadata from ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算文件的 SHA-256 hash(Base64URL 编码)
|
||||||
|
*/
|
||||||
|
private async calculateFileHash(url: string): Promise<string> {
|
||||||
|
// 检查缓存
|
||||||
|
const cacheKey = `hash:${url}`;
|
||||||
|
const cached = this.hashCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||||
|
return cached.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
const hash = crypto.createHash('sha256').update(response.data).digest('base64url');
|
||||||
|
|
||||||
|
// 缓存 hash
|
||||||
|
this.hashCache.set(cacheKey, { hash, timestamp: Date.now() });
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to calculate hash for ${url}: ${error.message}`);
|
||||||
|
throw new BadRequestException(`Failed to fetch asset: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 assets 列表(带真实 hash 计算)
|
||||||
|
*/
|
||||||
|
private async buildAssetsWithHash(assets: MetadataFileAsset[], baseUrl: string): Promise<AssetMetadata[]> {
|
||||||
|
// 去重:相同 path 的 asset 只保留一个
|
||||||
|
const uniqueAssets = new Map<string, MetadataFileAsset>();
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (!uniqueAssets.has(asset.path)) {
|
||||||
|
uniqueAssets.set(asset.path, asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetList = Array.from(uniqueAssets.values());
|
||||||
|
|
||||||
|
// 并行计算所有 asset 的 hash
|
||||||
|
const results = await Promise.all(
|
||||||
|
assetList.map(async (asset) => {
|
||||||
|
const url = baseUrl + asset.path;
|
||||||
|
const key = asset.path.split('/').pop() || ''; // 使用文件名作为 key
|
||||||
|
const hash = await this.calculateFileHash(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
key,
|
||||||
|
contentType: this.getContentType(asset.ext),
|
||||||
|
fileExtension: `.${asset.ext}`,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据扩展名获取 content type
|
||||||
|
*/
|
||||||
|
private getContentType(ext: string): string {
|
||||||
|
const contentTypes: Record<string, string> = {
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
ttf: 'font/ttf',
|
||||||
|
otf: 'font/otf',
|
||||||
|
woff: 'font/woff',
|
||||||
|
woff2: 'font/woff2',
|
||||||
|
js: 'application/javascript',
|
||||||
|
json: 'application/json',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
};
|
||||||
|
return contentTypes[ext.toLowerCase()] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
createNoUpdateAvailableDirective(): NoUpdateAvailableDirective {
|
createNoUpdateAvailableDirective(): NoUpdateAvailableDirective {
|
||||||
return { type: 'noUpdateAvailable' };
|
return { type: 'noUpdateAvailable' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 hash 转换为 UUID 格式
|
* 将 SHA-256 hash 转换为 UUID 格式
|
||||||
*/
|
*/
|
||||||
private hashToUUID(hash: string): string {
|
private convertSHA256HashToUUID(hash: string): string {
|
||||||
// 使用 hash 的前32个字符生成 UUID 格式
|
// 将 base64url 转为 hex,然后格式化为 UUID
|
||||||
const hex = Buffer.from(hash, 'base64url').toString('hex').padEnd(32, '0').slice(0, 32);
|
const hex = Buffer.from(hash, 'base64url').toString('hex').slice(0, 32);
|
||||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3873,14 +3873,15 @@ form-data-encoder@^2.1.2:
|
|||||||
resolved "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz"
|
resolved "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz"
|
||||||
integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==
|
integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==
|
||||||
|
|
||||||
form-data@^4.0.0:
|
form-data@^4.0.0, form-data@^4.0.5:
|
||||||
version "4.0.2"
|
version "4.0.5"
|
||||||
resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz"
|
resolved "https://mirrors.tencent.com/npm/form-data/-/form-data-4.0.5.tgz"
|
||||||
integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==
|
integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==
|
||||||
dependencies:
|
dependencies:
|
||||||
asynckit "^0.4.0"
|
asynckit "^0.4.0"
|
||||||
combined-stream "^1.0.8"
|
combined-stream "^1.0.8"
|
||||||
es-set-tostringtag "^2.1.0"
|
es-set-tostringtag "^2.1.0"
|
||||||
|
hasown "^2.0.2"
|
||||||
mime-types "^2.1.12"
|
mime-types "^2.1.12"
|
||||||
|
|
||||||
form-data@~2.3.2:
|
form-data@~2.3.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user