Files
2026-07-13 12:30:28 +08:00

1018 lines
26 KiB
TypeScript

import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import FormData from 'form-data';
import ora from 'ora';
import * as crypto from 'crypto';
import {
checkFileSizeLimit,
checkDirectorySizeLimit,
formatSize,
} from './uploadLimits';
import { createApiError } from './cliError';
import { saveUploadHistory } from './history';
import { getUid } from './getDeviceId';
import { getAuthHeaders } from './webLogin';
import { APP_CONFIG } from './config';
// Configuration constants
const IPFS_API_URL = APP_CONFIG.ipfsApiUrl;
const MAX_RETRIES = APP_CONFIG.upload.maxRetries;
const RETRY_DELAY = APP_CONFIG.upload.retryDelayMs;
const TIMEOUT = APP_CONFIG.upload.timeoutMs;
const MAX_POLL_TIME = APP_CONFIG.upload.maxPollTimeMs;
const POLL_INTERVAL = APP_CONFIG.upload.pollIntervalMs;
const COMPLETE_TO_STATUS_DELAY = 5000;
const PROGRESS_UPDATE_INTERVAL = 200; // ms
const EXPECTED_UPLOAD_TIME = 60000; // 60 seconds
const MAX_PROGRESS = 0.9; // 90%
const MAX_CONCURRENT_CHUNK_UPLOADS = 5;
// Type definitions
interface ChunkSessionResponse {
code: number;
msg: string;
data: {
session_id: string;
total_chunks: number;
chunk_size: number;
};
}
interface ChunkUploadResponse {
code: number;
msg: string;
data: {
chunk_index: number;
chunk_size: number;
};
}
interface ChunkCompleteResponse {
code: number;
msg: string;
data: {
trace_id: string;
};
}
interface ChunkStatusResponse {
code: number;
msg: string;
data: {
is_ready: boolean;
upload_rst: {
Bytes: number;
Name: string;
Size: number;
Hash?: string;
ShortUrl?: string;
pinme_domain?: string;
dns_domain?: string;
};
domain?: string;
};
}
interface UploadResult {
hash: string;
shortUrl?: string;
pinmeUrl?: string;
dnsUrl?: string;
}
export type UploadAction =
| 'upload'
| 'bind'
| 'import'
| 'project_create'
| 'project_save'
| 'project_update_web';
interface UploadExecutionOptions {
action?: UploadAction;
importAsCar?: boolean;
projectName?: string;
uid?: string;
}
function getUploadAuthHeaders(): Record<string, string> {
return getAuthHeaders();
}
function extractAxiosErrorMessage(error: any): string {
const responseData = error?.response?.data;
if (typeof responseData === 'string' && responseData.trim()) {
return responseData.trim();
}
const message =
responseData?.msg ||
responseData?.message ||
responseData?.error ||
responseData?.data?.msg ||
responseData?.data?.message ||
responseData?.data?.error;
if (typeof message === 'string' && message.trim()) {
return message.trim();
}
return error?.message || 'Unknown network error';
}
function formatAxiosError(prefix: string, error: any): Error {
return createApiError(
prefix,
error,
[],
);
}
function createUploadBusinessError(
stage: string,
endpoint: string,
method: 'GET' | 'POST',
status: number | undefined,
data: unknown,
): Error {
return createApiError(
stage,
{
response: {
status,
data,
},
config: {
url: endpoint,
method,
},
},
[`Endpoint: ${endpoint}`],
);
}
function logAxiosErrorDetails(
prefix: string,
error: any,
traceId?: string,
): void {
const status = error?.response?.status;
const responseData = error?.response?.data;
console.error(`[pinme upload] ${prefix}`);
if (traceId) {
console.error(`[pinme upload] trace_id: ${traceId}`);
}
console.error(`[pinme upload] status: ${status ?? 'unknown'}`);
if (responseData === undefined) {
console.error('[pinme upload] response: <empty>');
return;
}
try {
console.error(`[pinme upload] response: ${JSON.stringify(responseData)}`);
} catch {
console.error(`[pinme upload] response: ${String(responseData)}`);
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function appendTraceIdToError(error: unknown, traceId: string): Error {
const message = error instanceof Error ? error.message : String(error);
if (message.includes(`trace_id: ${traceId}`)) {
return error instanceof Error ? error : new Error(message);
}
return new Error(`${message} (trace_id: ${traceId})`);
}
function isStorageLimitError(error: any): boolean {
const message = extractAxiosErrorMessage(error).toLowerCase();
return (
message.includes('storage space limit') ||
message.includes('space limit reached') ||
message.includes('storage limit reached') ||
message.includes('quota exceeded') ||
message.includes('insufficient storage')
);
}
// Enhanced progress bar with better UX
class StepProgressBar {
private readonly spinner: ora.Ora;
private readonly fileName: string;
private readonly startTime: number;
private currentStep: number = 0;
private stepStartTime: number = 0;
private progressInterval: NodeJS.Timeout | null = null;
private isSimulatingProgress: boolean = false;
private simulationStartTime: number = 0;
constructor(fileName: string, isDirectory: boolean = false) {
this.fileName = fileName;
this.spinner = ora(`Preparing to upload ${fileName}...`).start();
this.startTime = Date.now();
this.stepStartTime = Date.now();
this.startProgress();
}
startStep(stepIndex: number, stepName?: string): void {
this.currentStep = stepIndex;
this.stepStartTime = Date.now();
}
updateProgress(progress: number, total: number): void {
// Chunk progress update handled by auto progress
}
completeStep(): void {
// Step completed, let auto progress continue
}
// Start simulating progress to continue display after 90%
startSimulatingProgress(): void {
this.isSimulatingProgress = true;
this.simulationStartTime = Date.now();
}
// Stop simulating progress
stopSimulatingProgress(): void {
this.isSimulatingProgress = false;
}
failStep(error: string): void {
this.stopProgress();
this.spinner.fail(`Upload failed: ${error}`);
}
complete(): void {
this.stopProgress();
const totalTime = Math.floor((Date.now() - this.startTime) / 1000);
const progressBar = this.createProgressBar(1);
this.spinner.succeed(
`Upload completed ${progressBar} 100% (${totalTime}s)`,
);
}
fail(error: string): void {
this.stopProgress();
const totalTime = Math.floor((Date.now() - this.startTime) / 1000);
this.spinner.fail(`Upload failed: ${error} (${totalTime}s)`);
}
private startProgress(): void {
this.progressInterval = setInterval(() => {
const elapsed = Date.now() - this.startTime;
let progress: number;
if (this.isSimulatingProgress) {
// Simulate progress after 90%, gradually grow from 90% to 99%
const simulationElapsed = Date.now() - this.simulationStartTime;
const simulationProgress = Math.min(simulationElapsed / 60000, 1); // From 90% to 99% within 60 seconds
progress = 0.9 + simulationProgress * 0.09; // 90% + 9% = 99%
} else {
progress = this.calculateProgress(elapsed);
}
const duration = this.formatDuration(Math.floor(elapsed / 1000));
const progressBar = this.createProgressBar(progress);
this.spinner.text = `Uploading ${
this.fileName
}... ${progressBar} ${Math.round(progress * 100)}% (${duration})`;
}, PROGRESS_UPDATE_INTERVAL);
}
private stopProgress(): void {
if (this.progressInterval) {
clearInterval(this.progressInterval);
this.progressInterval = null;
}
}
private calculateProgress(elapsed: number): number {
return Math.min(
(elapsed / EXPECTED_UPLOAD_TIME) * MAX_PROGRESS,
MAX_PROGRESS,
);
}
private createProgressBar(progress: number, width: number = 20): string {
const percentage = Math.min(progress, 1);
const filledWidth = Math.round(width * percentage);
const emptyWidth = width - filledWidth;
return `[${'█'.repeat(filledWidth)}${'░'.repeat(emptyWidth)}]`;
}
private formatDuration(seconds: number): string {
if (seconds < 60) {
return `${seconds}s`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return `${hours}h ${minutes}m ${remainingSeconds}s`;
}
}
}
// Utility functions
async function calculateMD5(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', hash.update.bind(hash));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
async function compressDirectory(sourcePath: string): Promise<string> {
return new Promise((resolve, reject) => {
// Use system temp directory instead of project directory to avoid recursive inclusion
const tempDir = require('os').tmpdir();
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const outputPath = path.join(
tempDir,
`pinme_${path.basename(sourcePath)}_${Date.now()}.zip`,
);
const output = fs.createWriteStream(outputPath);
const zlib = require('zlib');
const gzip = zlib.createGzip({ level: 9 });
output.on('close', () => resolve(outputPath));
gzip.on('error', reject);
gzip.pipe(output);
const stats = fs.statSync(sourcePath);
if (stats.isDirectory()) {
const archive = require('archiver');
const archiveStream = archive('zip', { zlib: { level: 9 } });
archiveStream.on('error', reject);
archiveStream.pipe(output);
archiveStream.directory(sourcePath, false);
archiveStream.finalize();
} else {
const fileStream = fs.createReadStream(sourcePath);
fileStream.pipe(gzip);
}
});
}
// API functions with better error handling
async function initChunkSession(
filePath: string,
deviceId: string,
options: UploadExecutionOptions = {},
isDirectory: boolean = false,
): Promise<ChunkSessionResponse['data']> {
const stats = fs.statSync(filePath);
const fileName = path.basename(filePath);
const fileSize = stats.size;
const md5 = await calculateMD5(filePath);
try {
const projectName = options.projectName?.trim();
const authHeaders = getUploadAuthHeaders();
const requestBody: any = {
file_name: fileName,
file_size: fileSize,
md5: md5,
is_directory: isDirectory,
uid: deviceId,
};
if (projectName) {
requestBody.project_name = projectName;
}
const response = await axios.post<ChunkSessionResponse>(
`${IPFS_API_URL}/chunk/init`,
requestBody,
{
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
...authHeaders,
},
},
);
const { code, msg, data } = response.data;
if (code === 200 && data) {
return data;
}
throw createUploadBusinessError(
'Session initialization failed',
`${IPFS_API_URL}/chunk/init`,
'POST',
response.status,
response.data,
);
} catch (error) {
if (axios.isAxiosError(error)) {
logAxiosErrorDetails('chunk/init failed', error);
throw formatAxiosError('Session initialization failed', error);
}
throw error;
}
}
async function uploadChunkWithAbort(
sessionId: string,
chunkIndex: number,
chunkData: Buffer,
deviceId: string,
signal: AbortSignal,
retryCount: number = 0,
): Promise<ChunkUploadResponse['data']> {
try {
if (signal.aborted) {
throw new Error('Request cancelled');
}
const form = new FormData();
form.append('session_id', sessionId);
form.append('chunk_index', chunkIndex.toString());
form.append('uid', deviceId);
form.append('chunk', chunkData, {
filename: `chunk_${chunkIndex}`,
contentType: 'application/octet-stream',
});
const response = await axios.post<ChunkUploadResponse>(
`${IPFS_API_URL}/chunk/upload`,
form,
{
headers: {
...form.getHeaders(),
...getUploadAuthHeaders(),
},
timeout: TIMEOUT,
signal,
},
);
const { code, msg, data } = response.data;
if (code === 200 && data) {
return data;
}
throw createUploadBusinessError(
'Chunk upload failed',
`${IPFS_API_URL}/chunk/upload`,
'POST',
response.status,
response.data,
);
} catch (error: any) {
if (error.name === 'CanceledError' || signal.aborted) {
throw new Error('Request cancelled');
}
if (axios.isAxiosError(error) && isStorageLimitError(error)) {
logAxiosErrorDetails('chunk/upload failed', error);
throw formatAxiosError('Chunk upload failed', error);
}
if (retryCount < MAX_RETRIES) {
await delayWithAbortCheck(RETRY_DELAY, signal);
return uploadChunkWithAbort(
sessionId,
chunkIndex,
chunkData,
deviceId,
signal,
retryCount + 1,
);
}
if (axios.isAxiosError(error)) {
logAxiosErrorDetails('chunk/upload failed', error);
throw formatAxiosError('Chunk upload failed', error);
}
throw new Error(
`Chunk ${chunkIndex + 1} upload failed after ${MAX_RETRIES} retries: ${
error.message
}`,
);
}
}
async function delayWithAbortCheck(
delay: number,
signal: AbortSignal,
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
if (signal.aborted) {
reject(new Error('Request cancelled'));
} else {
resolve();
}
}, delay);
if (signal.aborted) {
clearTimeout(timeoutId);
reject(new Error('Request cancelled'));
return;
}
const checkInterval = setInterval(() => {
if (signal.aborted) {
clearTimeout(timeoutId);
clearInterval(checkInterval);
reject(new Error('Request cancelled'));
}
}, 50);
});
}
async function runTasksWithConcurrency(
tasks: Array<() => Promise<void>>,
limit: number,
): Promise<PromiseSettledResult<void>[]> {
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < tasks.length) {
const task = tasks[nextIndex++];
await task();
}
}
const workerCount = Math.min(limit, tasks.length);
return Promise.allSettled(
Array.from({ length: workerCount }, () => worker()),
);
}
async function uploadFileChunks(
filePath: string,
sessionId: string,
totalChunks: number,
chunkSize: number,
deviceId: string,
progressBar: StepProgressBar,
): Promise<void> {
const fileData = fs.readFileSync(filePath);
const abortController = new AbortController();
let completedCount = 0;
let hasFatalError = false;
let fatalError: string | null = null;
const uploadTasks = Array.from({ length: totalChunks }, (_, chunkIndex) => {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, fileData.length);
const chunkData = fileData.slice(start, end);
return async () => {
if (abortController.signal.aborted) return;
try {
await uploadChunkWithAbort(
sessionId,
chunkIndex,
chunkData,
deviceId,
abortController.signal,
);
if (abortController.signal.aborted) return;
completedCount++;
progressBar.updateProgress(completedCount, totalChunks);
} catch (error: any) {
if (error.name === 'AbortError' || abortController.signal.aborted) {
return;
}
hasFatalError = true;
fatalError = `Chunk ${chunkIndex + 1}/${totalChunks} upload failed: ${
error.message
}`;
abortController.abort();
throw new Error(fatalError);
}
};
});
try {
const results = await runTasksWithConcurrency(
uploadTasks,
MAX_CONCURRENT_CHUNK_UPLOADS,
);
const failedResults = results.filter(
(result) => result.status === 'rejected',
);
if (failedResults.length > 0) {
const firstFailure = failedResults[0] as PromiseRejectedResult;
throw new Error(
firstFailure.reason.message || 'Error occurred during upload',
);
}
if (hasFatalError) {
throw new Error(fatalError || 'Unknown error occurred during upload');
}
} catch (error: any) {
throw fatalError ? new Error(fatalError) : error;
}
}
async function completeChunkUpload(
sessionId: string,
deviceId: string,
options: UploadExecutionOptions = {},
): Promise<string> {
try {
const action =
options.action || (options.importAsCar ? 'import' : 'upload');
const requestBody: any = { session_id: sessionId, uid: deviceId, action };
const projectName = options.projectName?.trim();
const authHeaders = getUploadAuthHeaders();
if (options.importAsCar) {
requestBody.import_as_car = true;
}
if (projectName) {
requestBody.project_name = projectName;
}
const response = await axios.post<ChunkCompleteResponse>(
`${IPFS_API_URL}/chunk/complete`,
requestBody,
{
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
...authHeaders,
},
},
);
const { code, msg, data } = response.data;
if (code === 200 && data) {
return data.trace_id;
}
throw createUploadBusinessError(
'Complete upload failed',
`${IPFS_API_URL}/chunk/complete`,
'POST',
response.status,
response.data,
);
} catch (error) {
if (axios.isAxiosError(error)) {
logAxiosErrorDetails('chunk/complete failed', error);
throw formatAxiosError('Complete upload failed', error);
}
throw error;
}
}
async function getChunkStatus(
sessionId: string,
deviceId: string,
options: UploadExecutionOptions = {},
): Promise<ChunkStatusResponse['data']> {
try {
const projectName = options.projectName?.trim();
const authHeaders = getUploadAuthHeaders();
const queryParams = new URLSearchParams({
trace_id: sessionId,
uid: deviceId,
});
if (projectName) {
queryParams.append('project_name', projectName);
}
const response = await axios.get<ChunkStatusResponse>(
`${IPFS_API_URL}/up_status?${queryParams.toString()}`,
{
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
...authHeaders,
},
},
);
const { code, msg, data } = response.data;
if (code === 200) {
return data;
}
throw createUploadBusinessError(
'Upload status check failed',
`${IPFS_API_URL}/up_status`,
'GET',
response.status,
response.data,
);
} catch (error) {
if (axios.isAxiosError(error)) {
logAxiosErrorDetails('up_status failed', error, sessionId);
throw formatAxiosError(
`Upload status check failed (trace_id: ${sessionId})`,
error,
);
}
console.error('[pinme upload] up_status failed');
console.error(`[pinme upload] trace_id: ${sessionId}`);
console.error(
`[pinme upload] reason: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw appendTraceIdToError(error, sessionId);
}
}
async function monitorChunkProgress(
traceId: string,
deviceId: string,
options: UploadExecutionOptions = {},
progressBar?: StepProgressBar,
): Promise<UploadResult | null> {
let consecutiveErrors = 0;
let startTime = Date.now();
// Start simulating progress
if (progressBar) {
progressBar.startSimulatingProgress();
}
try {
await delay(COMPLETE_TO_STATUS_DELAY);
startTime = Date.now();
while (Date.now() - startTime < MAX_POLL_TIME) {
try {
const status = await getChunkStatus(traceId, deviceId, options);
consecutiveErrors = 0;
if (status.is_ready && status.upload_rst.Hash) {
// Stop simulating progress
if (progressBar) {
progressBar.stopSimulatingProgress();
}
const shortUrl = status.upload_rst.ShortUrl;
const pinmeDomain = status.upload_rst.pinme_domain;
const dnsDomain = status.upload_rst.dns_domain;
return {
hash: status.upload_rst.Hash,
shortUrl,
pinmeUrl: pinmeDomain,
dnsUrl: dnsDomain,
};
}
} catch (error: any) {
consecutiveErrors++;
if (consecutiveErrors > 10) {
throw new Error(`Polling failed: ${error.message}`);
}
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
const maxPollTimeMinutes = Math.floor(MAX_POLL_TIME / (60 * 1000));
throw new Error(
`Polling timeout after ${maxPollTimeMinutes} minutes (trace_id: ${traceId})`,
);
} finally {
// Ensure simulating progress is stopped
if (progressBar) {
progressBar.stopSimulatingProgress();
}
}
}
// Main upload functions
async function uploadDirectoryInChunks(
directoryPath: string,
deviceId: string,
options: UploadExecutionOptions = {},
): Promise<UploadResult | null> {
const sizeCheck = checkDirectorySizeLimit(directoryPath);
if (sizeCheck.exceeds) {
throw new Error(
`Directory ${directoryPath} exceeds size limit ${formatSize(
sizeCheck.limit,
)} (size: ${formatSize(sizeCheck.size)})`,
);
}
const progressBar = new StepProgressBar(path.basename(directoryPath), true);
try {
progressBar.startStep(0, 'Preparing compression');
const compressedPath = await compressDirectory(directoryPath);
progressBar.completeStep();
progressBar.startStep(1, 'Initializing session');
const sessionInfo = await initChunkSession(
compressedPath,
deviceId,
options,
true,
);
progressBar.completeStep();
progressBar.startStep(2, 'Chunk upload');
await uploadFileChunks(
compressedPath,
sessionInfo.session_id,
sessionInfo.total_chunks,
sessionInfo.chunk_size,
deviceId,
progressBar,
);
progressBar.completeStep();
progressBar.startStep(3, 'Completing upload');
const traceId = await completeChunkUpload(
sessionInfo.session_id,
deviceId,
options,
);
progressBar.completeStep();
progressBar.startStep(4, 'Waiting for processing');
const result = await monitorChunkProgress(
traceId,
deviceId,
options,
progressBar,
);
progressBar.completeStep();
// Cleanup
try {
fs.unlinkSync(compressedPath);
} catch (error) {
// Ignore cleanup errors
}
// Save history
const uploadData = {
path: directoryPath,
filename: path.basename(directoryPath),
contentHash: result?.hash || 'unknown',
size: sizeCheck.size,
fileCount: 0,
isDirectory: true,
shortUrl: result?.shortUrl || null,
pinmeUrl: result?.pinmeUrl || null,
dnsUrl: result?.dnsUrl || null,
};
saveUploadHistory(uploadData);
if (!result?.hash) {
throw new Error('Server did not return valid hash value');
}
progressBar.complete();
return result;
} catch (error: any) {
progressBar.fail(error.message);
throw error;
}
}
async function uploadFileInChunks(
filePath: string,
deviceId: string,
options: UploadExecutionOptions = {},
): Promise<UploadResult | null> {
const sizeCheck = checkFileSizeLimit(filePath);
if (sizeCheck.exceeds) {
throw new Error(
`File ${filePath} exceeds size limit ${formatSize(
sizeCheck.limit,
)} (size: ${formatSize(sizeCheck.size)})`,
);
}
const fileName = path.basename(filePath);
const progressBar = new StepProgressBar(fileName, false);
try {
progressBar.startStep(0, 'Initializing session');
const sessionInfo = await initChunkSession(
filePath,
deviceId,
options,
false,
);
progressBar.completeStep();
progressBar.startStep(1, 'Chunk upload');
await uploadFileChunks(
filePath,
sessionInfo.session_id,
sessionInfo.total_chunks,
sessionInfo.chunk_size,
deviceId,
progressBar,
);
progressBar.completeStep();
progressBar.startStep(2, 'Completing upload');
const traceId = await completeChunkUpload(
sessionInfo.session_id,
deviceId,
options,
);
progressBar.completeStep();
progressBar.startStep(3, 'Waiting for processing');
const result = await monitorChunkProgress(
traceId,
deviceId,
options,
progressBar,
);
progressBar.completeStep();
// Save history
const uploadData = {
path: filePath,
filename: fileName,
contentHash: result?.hash || 'unknown',
previewHash: null,
size: sizeCheck.size,
fileCount: 1,
isDirectory: false,
shortUrl: result?.shortUrl || null,
pinmeUrl: result?.pinmeUrl || null,
dnsUrl: result?.dnsUrl || null,
};
saveUploadHistory(uploadData);
if (!result?.hash) {
throw new Error('Server did not return valid hash value');
}
progressBar.complete();
return result;
} catch (error: any) {
progressBar.fail(error.message);
throw error;
}
}
// Main export function
export default async function (
filePath: string,
options: UploadExecutionOptions = {},
): Promise<{
contentHash: string;
previewHash?: string | null;
shortUrl?: string;
pinmeUrl?: string;
dnsUrl?: string;
} | null> {
const uid = options.uid?.trim() || getUid();
if (!uid) {
throw new Error('Upload uid not found');
}
try {
const isDirectory = fs.statSync(filePath).isDirectory();
const result = isDirectory
? await uploadDirectoryInChunks(filePath, uid, options)
: await uploadFileInChunks(filePath, uid, options);
if (result?.hash) {
return {
contentHash: result.hash,
previewHash: null,
shortUrl: result.shortUrl,
pinmeUrl: result.pinmeUrl,
dnsUrl: result.dnsUrl,
};
}
throw new Error('Upload failed: no hash returned');
} catch (error: any) {
throw error;
}
}