330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
import path from 'path';
|
|
import { redis, subscriber } from './redis/redis.ts';
|
|
import { useFileStore } from '@abearxiong/use-file-store';
|
|
import { useConfig } from '@abearxiong/use-config';
|
|
import fs from 'fs';
|
|
import crypto from 'crypto';
|
|
import { nanoid } from 'nanoid';
|
|
import { pipeline } from 'stream';
|
|
import { promisify } from 'util';
|
|
const pipelineAsync = promisify(pipeline);
|
|
|
|
const { resources, api } = useConfig<{ resources: string; api: { host: string; testHost: string } }>();
|
|
const fileStore = useFileStore('upload');
|
|
|
|
const demoData = {
|
|
user: 'root',
|
|
key: 'codeflow',
|
|
appType: 'web-single', //
|
|
version: '1.0.0',
|
|
domain: null,
|
|
type: 'local',
|
|
data: {
|
|
files: [
|
|
{
|
|
name: 'index.html',
|
|
path: 'codeflow/index.html',
|
|
},
|
|
{
|
|
name: 'assets/index-14y4J8dP.js',
|
|
path: 'codeflow/assets/index-14y4J8dP.js',
|
|
},
|
|
{
|
|
name: 'assets/index-C-libw4a.css',
|
|
path: 'codeflow/assets/index-C-libw4a.css',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
type UserAppOptions = {
|
|
user: string;
|
|
app: string;
|
|
};
|
|
export class UserApp {
|
|
user: string;
|
|
app: string;
|
|
constructor(options: UserAppOptions) {
|
|
this.user = options.user;
|
|
this.app = options.app;
|
|
}
|
|
async getExist() {
|
|
const app = this.app;
|
|
const user = this.user;
|
|
const key = 'user:app:exist:' + app + ':' + user;
|
|
const value = await redis.get(key);
|
|
return value;
|
|
}
|
|
async getCache() {
|
|
const app = this.app;
|
|
const user = this.user;
|
|
const key = 'user:app:' + app + ':' + user;
|
|
const value = await redis.get(key);
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
}
|
|
async getFile(appFileUrl: string) {
|
|
const app = this.app;
|
|
const user = this.user;
|
|
const key = 'user:app:set:' + app + ':' + user;
|
|
const value = await redis.hget(key, appFileUrl);
|
|
return value;
|
|
}
|
|
static async getDomainApp(domain: string) {
|
|
const key = 'domain:' + domain;
|
|
const value = await redis.get(key);
|
|
if (value) {
|
|
const [_user, _app] = value.split(':');
|
|
return {
|
|
user: _user,
|
|
app: _app,
|
|
};
|
|
}
|
|
|
|
// 获取域名对应的用户和应用
|
|
const isDev = process.env.NODE_ENV === 'development';
|
|
const fetchTestUrl = 'http://' + api.testHost + '/api/router';
|
|
const fetchUrl = 'http://' + api.host + '/api/router';
|
|
const fetchRes = await fetch(isDev ? fetchTestUrl : fetchUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
path: 'app',
|
|
key: 'getDomainApp',
|
|
data: {
|
|
domain,
|
|
},
|
|
}),
|
|
}).then((res) => res.json());
|
|
if (fetchRes?.code !== 200) {
|
|
console.log('fetchRes is error', fetchRes);
|
|
return null;
|
|
}
|
|
const fetchData = fetchRes.data;
|
|
const data = {
|
|
user: fetchData.user,
|
|
app: fetchData.key,
|
|
};
|
|
redis.set(key, data.user + ':' + data.app, 'EX', 60 * 60 * 24 * 7); // 24小时
|
|
return data;
|
|
}
|
|
async setCacheData() {
|
|
const app = this.app;
|
|
const user = this.user;
|
|
const key = 'user:app:' + app + ':' + user;
|
|
const isDev = process.env.NODE_ENV === 'development';
|
|
const fetchTestUrl = 'http://' + api.testHost + '/api/router';
|
|
const fetchUrl = 'http://' + api.host + '/api/router';
|
|
const fetchRes = await fetch(isDev ? fetchTestUrl : fetchUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
path: 'app',
|
|
key: 'getApp',
|
|
data: {
|
|
user,
|
|
key: app,
|
|
},
|
|
}),
|
|
}).then((res) => res.json());
|
|
if (fetchRes?.code !== 200) {
|
|
console.log('fetchRes is error', fetchRes);
|
|
return false;
|
|
}
|
|
const fetchData = fetchRes.data;
|
|
if (!fetchData.type) {
|
|
// console.error('fetchData type is error', fetchData);
|
|
// return false;
|
|
fetchData.type = 'oss';
|
|
}
|
|
const value = await downloadUserAppFiles(user, app, fetchData);
|
|
if (value.data.files.length === 0) {
|
|
console.error('root files length is zero', user, app);
|
|
return false;
|
|
}
|
|
let valueIndexHtml = value.data.files.find((file) => file.name === 'index.html');
|
|
if (!valueIndexHtml) {
|
|
valueIndexHtml = value.data.files.find((file) => file.name === 'index.js');
|
|
if (!valueIndexHtml) {
|
|
valueIndexHtml = value.data.files[0];
|
|
}
|
|
}
|
|
await redis.set(key, JSON.stringify(value));
|
|
await redis.set('user:app:exist:' + app + ':' + user, valueIndexHtml.path, 'EX', 60 * 60 * 24 * 7); // 24小时
|
|
const files = value.data.files;
|
|
// await redis.hset(key, 'files', JSON.stringify(files));
|
|
const data = {};
|
|
|
|
// 将文件名和路径添加到 `data` 对象中
|
|
files.forEach((file) => {
|
|
data[file.name] = file.path;
|
|
});
|
|
await redis.hset('user:app:set:' + app + ':' + user, data);
|
|
|
|
return true;
|
|
}
|
|
async getAllCacheData() {
|
|
const app = this.app;
|
|
const user = this.user;
|
|
const key = 'user:app:' + app + ':' + user;
|
|
const value = await redis.get(key);
|
|
console.log('getAllCacheData', JSON.parse(value));
|
|
const exist = await redis.get('user:app:exist:' + app + ':' + user);
|
|
console.log('getAllCacheData:exist', exist);
|
|
const files = await redis.hgetall('user:app:set:' + app + ':' + user);
|
|
console.log('getAllCacheData:files', files);
|
|
}
|
|
async clearCacheData() {
|
|
const app = this.app;
|
|
const user = this.user;
|
|
const key = 'user:app:' + app + ':' + user;
|
|
await redis.del(key);
|
|
await redis.del('user:app:exist:' + app + ':' + user);
|
|
await redis.del('user:app:set:' + app + ':' + user);
|
|
console.log('clear user data', key);
|
|
// 删除所有文件
|
|
deleteUserAppFiles(user, app);
|
|
}
|
|
async close() {
|
|
// 关闭连接
|
|
await redis.quit();
|
|
}
|
|
}
|
|
export const downloadUserAppFiles = async (user: string, app: string, data: typeof demoData) => {
|
|
const {
|
|
data: { files, ...dataRest },
|
|
...rest
|
|
} = data;
|
|
const uploadFiles = path.join(fileStore, user, app);
|
|
if (!checkFileExistsSync(uploadFiles)) {
|
|
fs.mkdirSync(uploadFiles, { recursive: true });
|
|
}
|
|
const newFiles = [];
|
|
if (data.type === 'local') {
|
|
// local copy file
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
const copyFile = path.join(fileStore, file.path);
|
|
const destFile = path.join(uploadFiles, file.name);
|
|
const destDir = path.dirname(destFile); // 获取目标文件所在的目录路径
|
|
// 检查目录是否存在,如果不存在则创建
|
|
if (!checkFileExistsSync(destDir)) {
|
|
fs.mkdirSync(destDir, { recursive: true }); // 递归创建目录
|
|
}
|
|
fs.copyFileSync(copyFile, destFile);
|
|
// const etag = await setEtag(fs.readFileSync(destFile, 'utf-8'));
|
|
const etag = nanoid();
|
|
newFiles.push({
|
|
name: file.name,
|
|
path: destFile.replace(fileStore, '') + '||' + etag,
|
|
});
|
|
}
|
|
}
|
|
if (data.type === 'oss') {
|
|
const serverPath = 'https://' + resources + '/';
|
|
// server download file
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
const destFile = path.join(uploadFiles, file.name);
|
|
const destDir = path.dirname(destFile); // 获取目标文件所在的目录路径
|
|
// 检查目录是否存在,如果不存在则创建
|
|
if (!checkFileExistsSync(destDir)) {
|
|
fs.mkdirSync(destDir, { recursive: true }); // 递归创建目录
|
|
}
|
|
// 下载文件到 destFile
|
|
await downloadFile(serverPath + file.path, destFile);
|
|
const etag = nanoid();
|
|
newFiles.push({
|
|
name: file.name,
|
|
path: destFile.replace(fileStore, '') + '||' + etag,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
...rest,
|
|
data: {
|
|
...dataRest,
|
|
files: newFiles,
|
|
},
|
|
};
|
|
};
|
|
export const checkFileExistsSync = (filePath: string) => {
|
|
try {
|
|
// 使用 F_OK 检查文件或目录是否存在
|
|
fs.accessSync(filePath, fs.constants.F_OK);
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
};
|
|
export const deleteUserAppFiles = async (user: string, app: string) => {
|
|
const uploadFiles = path.join(fileStore, user, app);
|
|
try {
|
|
fs.rmSync(uploadFiles, { recursive: true });
|
|
} catch (err) {
|
|
console.error('deleteUserAppFiles', err);
|
|
}
|
|
// console.log('deleteUserAppFiles', res);
|
|
};
|
|
async function downloadFile(fileUrl: string, destFile: string) {
|
|
const res = await fetch(fileUrl);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to fetch ${fileUrl}: ${res.statusText}`);
|
|
}
|
|
console.log('destFile', destFile);
|
|
const destStream = fs.createWriteStream(destFile);
|
|
|
|
// 使用 `pipeline` 将 `res.body` 中的数据传递给 `destStream`
|
|
await pipelineAsync(res.body, destStream);
|
|
|
|
console.log(`File downloaded to ${destFile}`);
|
|
}
|
|
|
|
export const clearAllUserApp = async () => {
|
|
// redis 删除 所有的 user:app:*
|
|
const keys = await redis.keys('user:app:*');
|
|
console.log('clearAllUserApp', keys);
|
|
if (keys.length > 0) {
|
|
const pipeline = redis.pipeline();
|
|
keys.forEach((key) => pipeline.del(key)); // 将每个键的删除操作添加到 pipeline 中
|
|
await pipeline.exec(); // 执行 pipeline 中的所有命令
|
|
console.log('All keys deleted successfully using pipeline');
|
|
}
|
|
};
|
|
export const setEtag = async (fileContent: string) => {
|
|
const eTag = crypto.createHash('md5').update(fileContent).digest('hex');
|
|
return eTag;
|
|
};
|
|
|
|
// redis 监听 user:app:exist:*的过期
|
|
subscriber.on('ready', () => {
|
|
console.log('Subscriber is ready and connected.');
|
|
});
|
|
|
|
// 订阅 Redis 频道
|
|
subscriber.subscribe('__keyevent@0__:expired', (err, count) => {
|
|
if (err) {
|
|
console.error('Failed to subscribe: ', err);
|
|
} else {
|
|
console.log(`Subscribed to ${count} channel(s). Waiting for expired events...`);
|
|
}
|
|
});
|
|
|
|
// 监听消息事件
|
|
subscriber.on('message', (channel, message) => {
|
|
// 检查是否匹配 user:app:exist:* 模式
|
|
if (message.startsWith('user:app:exist:')) {
|
|
const [_user, _app, _exist, app, user] = message.split(':');
|
|
// 在这里执行你的逻辑,例如清理缓存或通知用户
|
|
console.log('User app exist key expired:', app, user);
|
|
const userApp = new UserApp({ user, app });
|
|
userApp.clearCacheData();
|
|
}
|
|
});
|