237 lines
7.1 KiB
TypeScript
237 lines
7.1 KiB
TypeScript
import { useFileStore } from '@kevisual/use-config';
|
|
import { checkAuth, error, router, writeEvents, getKey, getTaskId } from '../router.ts';
|
|
import Busboy from 'busboy';
|
|
import { app, oss } from '@/app.ts';
|
|
|
|
import { getContentType } from '@/utils/get-content-type.ts';
|
|
import { User } from '@/models/user.ts';
|
|
import fs from 'fs';
|
|
import { ConfigModel } from '@/routes/config/models/model.ts';
|
|
import { validateDirectory } from './util.ts';
|
|
import path from 'path';
|
|
import { createWriteStream } from 'fs';
|
|
import { pipeBusboy } from '@/modules/fm-manager/index.ts';
|
|
|
|
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
|
|
|
router.get('/api/s1/resources/upload/chunk', async (req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('Upload API is ready');
|
|
});
|
|
|
|
// /api/s1/resources/upload
|
|
router.post('/api/s1/resources/upload/chunk', async (req, res) => {
|
|
const { tokenUser, token } = await checkAuth(req, res);
|
|
if (!tokenUser) return;
|
|
const url = new URL(req.url || '', 'http://localhost');
|
|
const share = !!url.searchParams.get('public');
|
|
const noCheckAppFiles = !!url.searchParams.get('noCheckAppFiles');
|
|
|
|
const taskId = getTaskId(req);
|
|
const finalFilePath = `${cacheFilePath}/${taskId}`;
|
|
if (!taskId) {
|
|
res.end(error('taskId is required'));
|
|
return;
|
|
}
|
|
|
|
// 使用 busboy 解析 multipart/form-data
|
|
const busboy = Busboy({ headers: req.headers, preservePath: true });
|
|
const fields: any = {};
|
|
let file: any = null;
|
|
let tempPath = '';
|
|
let filePromise: Promise<void> | null = null;
|
|
|
|
busboy.on('field', (fieldname, value) => {
|
|
fields[fieldname] = value;
|
|
});
|
|
|
|
busboy.on('file', (fieldname, fileStream, info) => {
|
|
const { filename, encoding, mimeType } = info;
|
|
tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
|
|
const writeStream = createWriteStream(tempPath);
|
|
|
|
filePromise = new Promise<void>((resolve, reject) => {
|
|
fileStream.pipe(writeStream);
|
|
|
|
writeStream.on('finish', () => {
|
|
file = {
|
|
filepath: tempPath,
|
|
originalFilename: filename,
|
|
mimetype: mimeType,
|
|
};
|
|
resolve();
|
|
});
|
|
|
|
writeStream.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
});
|
|
});
|
|
|
|
busboy.on('finish', async () => {
|
|
// 等待文件写入完成
|
|
if (filePromise) {
|
|
try {
|
|
await filePromise;
|
|
} catch (err) {
|
|
console.error(`File write error: ${err.message}`);
|
|
res.end(error(`File write error: ${err.message}`));
|
|
return;
|
|
}
|
|
}
|
|
const clearFiles = () => {
|
|
if (tempPath && fs.existsSync(tempPath)) {
|
|
fs.unlinkSync(tempPath);
|
|
}
|
|
if (fs.existsSync(finalFilePath)) {
|
|
fs.unlinkSync(finalFilePath);
|
|
}
|
|
};
|
|
|
|
if (!file) {
|
|
res.end(error('No file uploaded'));
|
|
return;
|
|
}
|
|
|
|
// Handle chunked upload logic here
|
|
let { chunkIndex, totalChunks, appKey, version, username, directory } = getKey(fields, [
|
|
'chunkIndex',
|
|
'totalChunks',
|
|
'appKey',
|
|
'version',
|
|
'username',
|
|
'directory',
|
|
]);
|
|
if (!chunkIndex || !totalChunks) {
|
|
res.end(error('chunkIndex, totalChunks is required'));
|
|
clearFiles();
|
|
return;
|
|
}
|
|
const relativePath = file.originalFilename;
|
|
|
|
const writeStream = fs.createWriteStream(finalFilePath, { flags: 'a' });
|
|
const readStream = fs.createReadStream(tempPath);
|
|
readStream.pipe(writeStream);
|
|
|
|
writeStream.on('finish', async () => {
|
|
fs.unlinkSync(tempPath); // 删除临时文件
|
|
|
|
// Write event for progress tracking
|
|
const progress = ((parseInt(chunkIndex) + 1) / parseInt(totalChunks)) * 100;
|
|
writeEvents(req, {
|
|
progress,
|
|
message: `Upload progress: ${progress}%`,
|
|
});
|
|
|
|
if (parseInt(chunkIndex) + 1 === parseInt(totalChunks)) {
|
|
let uid = tokenUser.id;
|
|
if (username) {
|
|
const user = await User.getUserByToken(token);
|
|
const has = await user.hasUser(username, true);
|
|
if (!has) {
|
|
res.end(error('username is not found'));
|
|
clearFiles();
|
|
return;
|
|
}
|
|
const _user = await User.findOne({ where: { username } });
|
|
uid = _user?.id || '';
|
|
}
|
|
if (!appKey || !version) {
|
|
const config = await ConfigModel.getUploadConfig({ uid });
|
|
if (config) {
|
|
appKey = config.config?.data?.key || '';
|
|
version = config.config?.data?.version || '';
|
|
}
|
|
}
|
|
if (!appKey || !version) {
|
|
res.end(error('appKey or version is not found, please check the upload config.'));
|
|
clearFiles();
|
|
return;
|
|
}
|
|
const { code, message } = validateDirectory(directory);
|
|
if (code !== 200) {
|
|
res.end(error(message));
|
|
clearFiles();
|
|
return;
|
|
}
|
|
const minioPath = `${username || tokenUser.username}/${appKey}/${version}${directory ? `/${directory}` : ''}/${relativePath}`;
|
|
const metadata: any = {};
|
|
if (share) {
|
|
metadata.share = 'public';
|
|
}
|
|
const bucketName = oss.bucketName;
|
|
// All chunks uploaded, now upload to MinIO
|
|
await oss.client.fPutObject(bucketName, minioPath, finalFilePath, {
|
|
'Content-Type': getContentType(relativePath),
|
|
'app-source': 'user-app',
|
|
'Cache-Control': relativePath.endsWith('.html') ? 'no-cache' : 'max-age=31536000, immutable',
|
|
...metadata,
|
|
});
|
|
|
|
// Clean up the final file
|
|
fs.unlinkSync(finalFilePath);
|
|
const downloadBase = '/api/s1/share';
|
|
|
|
const uploadResult = {
|
|
name: relativePath,
|
|
path: `${downloadBase}/${minioPath}`,
|
|
appKey,
|
|
version,
|
|
username,
|
|
};
|
|
if (!noCheckAppFiles) {
|
|
// Notify the app
|
|
const r = await app.call({
|
|
path: 'app',
|
|
key: 'detectVersionList',
|
|
payload: {
|
|
token: token,
|
|
data: {
|
|
appKey,
|
|
version,
|
|
username,
|
|
},
|
|
},
|
|
});
|
|
const data: any = {
|
|
code: r.code,
|
|
data: {
|
|
app: r.body,
|
|
upload: [uploadResult],
|
|
},
|
|
};
|
|
if (r.message) {
|
|
data.message = r.message;
|
|
}
|
|
console.log('upload data', data);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
} else {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
code: 200,
|
|
message: 'Chunk uploaded successfully',
|
|
data: { chunkIndex, totalChunks, upload: [uploadResult] },
|
|
}),
|
|
);
|
|
}
|
|
} else {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
code: 200,
|
|
message: 'Chunk uploaded successfully',
|
|
data: {
|
|
chunkIndex,
|
|
totalChunks,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
pipeBusboy(req, res, busboy);
|
|
});
|