297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
import { bucketName, minioClient } from '@/modules/minio.ts';
|
||
import { IncomingMessage, ServerResponse } from 'http';
|
||
import { filterKeys } from './http-proxy.ts';
|
||
import { getUserFromRequest } from '../utils.ts';
|
||
import { UserPermission, Permission } from '@kevisual/permission';
|
||
import { getLoginUser } from '@/modules/auth.ts';
|
||
import busboy from 'busboy';
|
||
import { getContentType } from '../get-content-type.ts';
|
||
import { OssBase } from '@kevisual/oss';
|
||
import { parseSearchValue } from '@kevisual/router/browser';
|
||
import { logger } from '@/modules/logger.ts';
|
||
|
||
type FileList = {
|
||
name: string;
|
||
prefix?: string;
|
||
size?: number;
|
||
etag?: string;
|
||
lastModified?: Date;
|
||
|
||
path?: string;
|
||
url?: string;
|
||
pathname?: string;
|
||
};
|
||
export const getFileList = async (list: any, opts?: { objectName: string; app: string; host?: string }) => {
|
||
const { app, host } = opts || {};
|
||
const objectName = opts?.objectName || '';
|
||
let newObjectName = objectName;
|
||
const [user] = objectName.split('/');
|
||
let replaceUser = user + '/';
|
||
if (app === 'resources') {
|
||
replaceUser = `${user}/resources/`;
|
||
newObjectName = objectName.replace(`${user}/`, replaceUser);
|
||
}
|
||
return list.map((item: FileList) => {
|
||
if (item.name) {
|
||
item.path = item.name?.replace?.(objectName, '');
|
||
item.pathname = '/' + item.name.replace(`${user}/`, replaceUser);
|
||
} else {
|
||
item.path = item.prefix?.replace?.(objectName, '');
|
||
item.pathname = '/' + item.prefix.replace(`${user}/`, replaceUser);
|
||
}
|
||
if (item.name && app === 'ai') {
|
||
const [_user, _app, _version, ...rest] = item.name.split('/');
|
||
item.pathname = item.pathname.replace(`/${_user}/${_app}/${_version}/`, `/${_user}/${_app}/`);
|
||
} else if (app === 'ai') {
|
||
const [_user, _app, _version, ...rest] = item.prefix?.split('/');
|
||
item.pathname = item.pathname.replace(`/${_user}/${_app}/${_version}/`, `/${_user}/${_app}/`);
|
||
}
|
||
item.url = new URL(item.pathname, `https://${host}`).toString();
|
||
return item;
|
||
});
|
||
};
|
||
// import { logger } from '@/module/logger.ts';
|
||
const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||
const { createNotFoundPage } = opts;
|
||
const _u = new URL(req.url, 'http://localhost');
|
||
const oss = opts.oss;
|
||
const params = _u.searchParams;
|
||
const password = params.get('p');
|
||
const hash = params.get('hash');
|
||
let dir = !!params.get('dir');
|
||
const recursive = !!params.get('recursive');
|
||
const { objectName, app, owner, loginUser, isOwner } = await getObjectName(req);
|
||
if (!dir && _u.pathname.endsWith('/')) {
|
||
dir = true; // 如果是目录请求,强制设置为true
|
||
}
|
||
logger.debug(`proxy request: ${objectName}`, dir);
|
||
try {
|
||
if (dir) {
|
||
if (!isOwner) {
|
||
return createNotFoundPage('no dir permission');
|
||
}
|
||
const list = await oss.listObjects<true>(objectName, { recursive: recursive });
|
||
res.writeHead(200, { 'content-type': 'application/json' });
|
||
const host = req.headers['host'] || 'localhost';
|
||
res.end(
|
||
JSON.stringify({
|
||
code: 200,
|
||
data: await getFileList(list, {
|
||
objectName: objectName,
|
||
app: app,
|
||
host,
|
||
}),
|
||
}),
|
||
);
|
||
return true;
|
||
}
|
||
const stat = await oss.statObject(objectName);
|
||
if (!stat) {
|
||
createNotFoundPage('Invalid proxy url');
|
||
logger.debug('no stat', objectName, owner, req.url);
|
||
return true;
|
||
}
|
||
const permissionInstance = new UserPermission({ permission: stat.metaData as Permission, owner: owner });
|
||
const checkPermission = permissionInstance.checkPermissionSuccess({
|
||
username: loginUser?.tokenUser?.username || '',
|
||
password: password,
|
||
});
|
||
if (!checkPermission.success) {
|
||
logger.info('no permission', checkPermission, loginUser, owner);
|
||
return createNotFoundPage('no permission');
|
||
}
|
||
if (hash && stat.etag === hash) {
|
||
res.writeHead(304); // not modified
|
||
res.end('not modified');
|
||
return true;
|
||
}
|
||
const filterMetaData = filterKeys(stat.metaData, ['size', 'etag', 'last-modified']);
|
||
const contentLength = stat.size;
|
||
const etag = stat.etag;
|
||
const lastModified = stat.lastModified.toISOString();
|
||
|
||
const objectStream = await minioClient.getObject(bucketName, objectName);
|
||
const headers = {
|
||
'Content-Length': contentLength,
|
||
etag,
|
||
'last-modified': lastModified,
|
||
...filterMetaData,
|
||
};
|
||
|
||
res.writeHead(200, {
|
||
...headers,
|
||
});
|
||
objectStream.pipe(res, { end: true });
|
||
return true;
|
||
} catch (error) {
|
||
console.error(`Proxy request error: ${error.message}`);
|
||
createNotFoundPage('Invalid ai proxy url');
|
||
return false;
|
||
}
|
||
};
|
||
export const getMetadata = (pathname: string) => {
|
||
let meta: any = { 'app-source': 'user-app' };
|
||
const isHtml = pathname.endsWith('.html');
|
||
if (isHtml) {
|
||
meta = {
|
||
...meta,
|
||
'content-type': 'text/html; charset=utf-8',
|
||
'cache-control': 'no-cache',
|
||
};
|
||
} else {
|
||
meta = {
|
||
...meta,
|
||
'content-type': getContentType(pathname),
|
||
'cache-control': 'max-age=31536000, immutable',
|
||
};
|
||
}
|
||
return meta;
|
||
};
|
||
|
||
export const postProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||
const _u = new URL(req.url, 'http://localhost');
|
||
|
||
const pathname = _u.pathname;
|
||
const oss = opts.oss;
|
||
const params = _u.searchParams;
|
||
const force = !!params.get('force');
|
||
const hash = params.get('hash');
|
||
const _fileSize: string = params.get('size');
|
||
let fileSize: number | undefined = undefined;
|
||
if (_fileSize) {
|
||
fileSize = parseInt(_fileSize, 10)
|
||
}
|
||
let meta = parseSearchValue(params.get('meta'), { decode: true });
|
||
if (!hash && !force) {
|
||
return opts?.createNotFoundPage?.('no hash');
|
||
}
|
||
const { objectName, isOwner } = await getObjectName(req);
|
||
if (!isOwner) {
|
||
return opts?.createNotFoundPage?.('no permission');
|
||
}
|
||
const end = (data: any, message?: string, code = 200) => {
|
||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ code: code, data: data, message: message || 'success' }));
|
||
};
|
||
let statMeta: any = {};
|
||
if (!force) {
|
||
const check = await oss.checkObjectHash(objectName, hash, meta);
|
||
statMeta = check?.metaData || {};
|
||
let isNewMeta = false;
|
||
if (check.success && JSON.stringify(meta) !== '{}' && !check.equalMeta) {
|
||
meta = { ...statMeta, ...getMetadata(pathname), ...meta };
|
||
isNewMeta = true;
|
||
await oss.replaceObject(objectName, { ...meta });
|
||
}
|
||
if (check.success) {
|
||
return end({ success: true, hash, meta, isNewMeta, equalMeta: check.equalMeta }, '文件已存在');
|
||
}
|
||
}
|
||
const bb = busboy({
|
||
headers: req.headers,
|
||
limits: {
|
||
fileSize: 100 * 1024 * 1024, // 100MB
|
||
files: 1,
|
||
},
|
||
});
|
||
let fileProcessed = false;
|
||
bb.on('file', async (name, file, info) => {
|
||
fileProcessed = true;
|
||
try {
|
||
await oss.putObject(
|
||
objectName,
|
||
file,
|
||
{
|
||
...statMeta,
|
||
...getMetadata(pathname),
|
||
...meta,
|
||
},
|
||
{ check: false, isStream: true, size: fileSize },
|
||
);
|
||
end({ success: true, name, info, isNew: true, hash, meta: meta?.metaData, statMeta }, '上传成功', 200);
|
||
|
||
} catch (error) {
|
||
end({ error: error }, '上传失败', 500);
|
||
}
|
||
});
|
||
|
||
bb.on('finish', () => {
|
||
// 只有当没有文件被处理时才执行end
|
||
if (!fileProcessed) {
|
||
end({ success: false }, '没有接收到文件', 400);
|
||
}
|
||
});
|
||
bb.on('error', (err) => {
|
||
console.error('Busboy 错误:', err);
|
||
end({ error: err }, '文件解析失败', 500);
|
||
});
|
||
|
||
req.pipe(bb);
|
||
};
|
||
export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?: boolean }) => {
|
||
const _u = new URL(req.url, 'http://localhost');
|
||
const pathname = decodeURIComponent(_u.pathname);
|
||
const params = _u.searchParams;
|
||
const { user, app } = getUserFromRequest(req);
|
||
const checkOwner = opts?.checkOwner ?? true;
|
||
let objectName = '';
|
||
let owner = '';
|
||
if (app === 'ai') {
|
||
const version = params.get('version') || '1.0.0'; // root/ai
|
||
objectName = pathname.replace(`/${user}/${app}/`, `${user}/${app}/${version}/`);
|
||
} else {
|
||
objectName = pathname.replace(`/${user}/${app}/`, `${user}/`); // root/resources
|
||
}
|
||
owner = user;
|
||
let isOwner = undefined;
|
||
let loginUser: Awaited<ReturnType<typeof getLoginUser>> = null;
|
||
if (checkOwner) {
|
||
loginUser = await getLoginUser(req);
|
||
logger.debug('getObjectName', loginUser, user, app);
|
||
isOwner = loginUser?.tokenUser?.username === owner;
|
||
}
|
||
return {
|
||
objectName,
|
||
loginUser,
|
||
owner,
|
||
isOwner,
|
||
app,
|
||
user,
|
||
};
|
||
};
|
||
export const deleteProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||
const { objectName, isOwner } = await getObjectName(req);
|
||
let oss = opts.oss;
|
||
if (!isOwner) {
|
||
return opts?.createNotFoundPage?.('no permission');
|
||
}
|
||
try {
|
||
await oss.deleteObject(objectName);
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ success: true, message: 'delete success', objectName }));
|
||
} catch (error) {
|
||
logger.error('deleteProxy error', error);
|
||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ success: false, error: error }));
|
||
}
|
||
};
|
||
|
||
type ProxyOptions = {
|
||
createNotFoundPage: (msg?: string) => any;
|
||
oss?: OssBase;
|
||
};
|
||
export const aiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||
const oss = new OssBase({ bucketName, client: minioClient });
|
||
if (!opts.oss) {
|
||
opts.oss = oss;
|
||
}
|
||
if (req.method === 'POST') {
|
||
return postProxy(req, res, opts);
|
||
}
|
||
if (req.method === 'DELETE') {
|
||
return deleteProxy(req, res, opts);
|
||
}
|
||
|
||
return getAiProxy(req, res, opts);
|
||
};
|