update
This commit is contained in:
30
src/modules/user-app/get-app-status.ts
Normal file
30
src/modules/user-app/get-app-status.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { redis } from '../redis.ts';
|
||||
|
||||
export type AppLoadStatus = {
|
||||
status: 'running' | 'loading' | 'error' | 'not-exist';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const getAppLoadStatus = async (user: string, app: string): Promise<AppLoadStatus> => {
|
||||
const key = 'user:app:status:' + app + ':' + user;
|
||||
const value = await redis.get(key);
|
||||
if (!value) {
|
||||
return {
|
||||
status: 'not-exist',
|
||||
message: 'not-exist',
|
||||
}; // 没有加载过
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'error',
|
||||
};
|
||||
}
|
||||
};
|
||||
export const setAppLoadStatus = async (user: string, app: string, status: AppLoadStatus, exp = 3 * 60) => {
|
||||
const key = 'user:app:status:' + app + ':' + user;
|
||||
const value = JSON.stringify(status);
|
||||
await redis.set(key, value, 'EX', exp); // 5分钟过期
|
||||
};
|
||||
464
src/modules/user-app/index.ts
Normal file
464
src/modules/user-app/index.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import path from 'path';
|
||||
import { redis, subscriber } from '../redis.ts';
|
||||
import { myConfig as config, fileStore } from '../config.ts';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { getAppLoadStatus, setAppLoadStatus } from './get-app-status.ts';
|
||||
import { minioResources } from '../minio.ts';
|
||||
import { downloadFileFromMinio, fetchApp, fetchDomain, fetchTest } from '@/modules/fm-manager/index.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
export * from './get-app-status.ts';
|
||||
export * from './user-home.ts';
|
||||
|
||||
const pipelineAsync = promisify(pipeline);
|
||||
|
||||
const { resources } = config?.proxy || { resources: minioResources };
|
||||
const wrapperResources = (resources: string, urlpath: string) => {
|
||||
if (urlpath.startsWith('http')) {
|
||||
return urlpath;
|
||||
}
|
||||
return `${resources}/${urlpath}`;
|
||||
};
|
||||
const demoData = {
|
||||
user: 'root',
|
||||
key: 'codeflow',
|
||||
appType: 'web-single', //
|
||||
version: '1.0.0',
|
||||
domain: null,
|
||||
type: 'oss', // oss, 默认是oss
|
||||
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;
|
||||
isTest: boolean;
|
||||
constructor(options: UserAppOptions) {
|
||||
this.user = options.user;
|
||||
this.app = options.app;
|
||||
if (this.user === 'test') {
|
||||
this.isTest = true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 是否已经加载到本地了
|
||||
* @returns
|
||||
*/
|
||||
async getExist() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const key = 'user:app:exist:' + app + ':' + user;
|
||||
const permissionKey = 'user:app:permission:' + app + ':' + user;
|
||||
const value = await redis.get(key);
|
||||
const permission = await redis.get(permissionKey);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const [indexFilePath, etag, proxy] = value.split('||');
|
||||
try {
|
||||
return {
|
||||
indexFilePath,
|
||||
etag,
|
||||
proxy: proxy === 'true',
|
||||
permission: permission ? JSON.parse(permission) : { share: 'private' },
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('getExist error parse', e);
|
||||
await this.clearCacheData();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取缓存数据,不存在不会加载
|
||||
* @returns
|
||||
*/
|
||||
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;
|
||||
}
|
||||
return JSON.parse(value);
|
||||
}
|
||||
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);
|
||||
// const values = await redis.hgetall(key);
|
||||
// console.log('getFile', values);
|
||||
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 fetchRes = await fetchDomain(domain).catch((err) => {
|
||||
return {
|
||||
code: 500,
|
||||
message: err,
|
||||
};
|
||||
});
|
||||
if (fetchRes?.code !== 200) {
|
||||
console.log('fetchRes is error', fetchRes);
|
||||
return null;
|
||||
}
|
||||
const fetchData = fetchRes.data;
|
||||
if (fetchData.status !== 'running') {
|
||||
console.error('fetchData status is not running', fetchData.user, fetchData.key);
|
||||
return null;
|
||||
}
|
||||
const data = {
|
||||
user: fetchData.user,
|
||||
app: fetchData.key,
|
||||
};
|
||||
redis.set(key, data.user + ':' + data.app, 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
|
||||
const userDomainApp = 'user:domain:app:' + data.user + ':' + data.app;
|
||||
|
||||
const domainKeys = await redis.get(userDomainApp);
|
||||
let domainKeysList = domainKeys ? JSON.parse(domainKeys) : [];
|
||||
domainKeysList.push(domain);
|
||||
const uniq = (arr: string[]) => {
|
||||
return [...new Set(arr)];
|
||||
};
|
||||
domainKeysList = uniq(domainKeysList);
|
||||
await redis.set(userDomainApp, JSON.stringify(domainKeysList), 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
return data;
|
||||
}
|
||||
/**
|
||||
* 加载结束
|
||||
* @param msg
|
||||
*/
|
||||
async setLoaded(status: 'running' | 'error' | 'loading', msg?: string) {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
await setAppLoadStatus(user, app, {
|
||||
status,
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 获取加载状态
|
||||
* @returns
|
||||
*/
|
||||
async getLoaded() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const value = await getAppLoadStatus(user, app);
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* 设置缓存数据,当出问题了,就重新加载。
|
||||
* @returns
|
||||
*/
|
||||
async setCacheData() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const isTest = this.isTest;
|
||||
const key = 'user:app:' + app + ':' + user;
|
||||
const fetchRes = isTest ? await fetchTest(app) : await fetchApp({ user, app });
|
||||
if (fetchRes?.code !== 200) {
|
||||
console.log('获取缓存的cache错误', fetchRes, 'user', user, 'app', app);
|
||||
return { code: 500, message: 'fetchRes is error' };
|
||||
}
|
||||
|
||||
const loadStatus = await getAppLoadStatus(user, app);
|
||||
logger.debug('loadStatus', loadStatus);
|
||||
if (loadStatus.status === 'loading') {
|
||||
// 其他情况,error或者running都可以重新加载
|
||||
return {
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
|
||||
const fetchData = fetchRes.data;
|
||||
if (!fetchData.type) {
|
||||
// console.error('fetchData type is error', fetchData);
|
||||
// return false;
|
||||
fetchData.type = 'oss';
|
||||
}
|
||||
if (fetchData.status !== 'running') {
|
||||
console.error('fetchData status is not running', fetchData.user, fetchData.key);
|
||||
return { code: 500, message: 'app status is not running' };
|
||||
}
|
||||
// console.log('fetchData', JSON.stringify(fetchData.data.files, null, 2));
|
||||
// const getFileSize
|
||||
this.setLoaded('loading', 'loading');
|
||||
const loadProxy = async () => {
|
||||
const value = fetchData;
|
||||
await redis.set(key, JSON.stringify(value));
|
||||
const version = value.version;
|
||||
let indexHtml = resources + '/' + user + '/' + app + '/' + version + '/index.html';
|
||||
const files = value?.data?.files || [];
|
||||
const permission = value?.data?.permission || { share: 'private' };
|
||||
const data = {};
|
||||
|
||||
// 将文件名和路径添加到 `data` 对象中
|
||||
files.forEach((file) => {
|
||||
if (file.name === 'index.html') {
|
||||
indexHtml = wrapperResources(resources, file.path);
|
||||
}
|
||||
data[file.name] = wrapperResources(resources, file.path);
|
||||
});
|
||||
await redis.set('user:app:exist:' + app + ':' + user, indexHtml + '||etag||true', 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
await redis.set('user:app:permission:' + app + ':' + user, JSON.stringify(permission), 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
await redis.hset('user:app:set:' + app + ':' + user, data);
|
||||
this.setLoaded('running', 'loaded');
|
||||
};
|
||||
const loadFilesFn = async () => {
|
||||
const value = await downloadUserAppFiles(user, app, fetchData);
|
||||
if (value.data.files.length === 0) {
|
||||
console.error('root files length is zero', user, app);
|
||||
this.setLoaded('running', 'root files length is zero');
|
||||
const mockPath = path.join(fileStore, user, app, 'index.html');
|
||||
value.data.files = [
|
||||
{
|
||||
name: 'index.html', // 映射
|
||||
path: mockPath.replace(fileStore, ''), // 实际
|
||||
},
|
||||
];
|
||||
if (!checkFileExistsSync(path.join(fileStore, user, app))) {
|
||||
fs.mkdirSync(path.join(fileStore, user, app), { recursive: true });
|
||||
}
|
||||
// 自己创建一个index.html
|
||||
fs.writeFileSync(path.join(fileStore, user, app, 'index.html'), 'not has any app info', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
await redis.set(key, JSON.stringify(value));
|
||||
const files = value.data.files;
|
||||
const permission = fetchData?.data?.permission || { share: 'private' };
|
||||
const data = {};
|
||||
let indexHtml = path.join(fileStore, user, app, 'index.html') + '||etag||false';
|
||||
// 将文件名和路径添加到 `data` 对象中
|
||||
files.forEach((file) => {
|
||||
data[file.name] = file.path;
|
||||
if (file.name === 'index.html') {
|
||||
indexHtml = file.path;
|
||||
}
|
||||
});
|
||||
await redis.set('user:app:exist:' + app + ':' + user, indexHtml, 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
await redis.set('user:app:permission:' + app + ':' + user, JSON.stringify(permission), 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
await redis.hset('user:app:set:' + app + ':' + user, data);
|
||||
this.setLoaded('running', 'loaded');
|
||||
};
|
||||
logger.debug('loadFilesFn', fetchData.proxy);
|
||||
try {
|
||||
if (fetchData.proxy === true) {
|
||||
await loadProxy();
|
||||
return {
|
||||
code: 200,
|
||||
data: 'loaded',
|
||||
};
|
||||
} else {
|
||||
loadFilesFn();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('loadFilesFn error', e);
|
||||
this.setLoaded('error', 'loadFilesFn error');
|
||||
}
|
||||
return {
|
||||
code: 20000,
|
||||
data: 'loading',
|
||||
};
|
||||
}
|
||||
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);
|
||||
await redis.del('user:app:status:' + app + ':' + user);
|
||||
await redis.del('user:app:permission:' + app + ':' + user);
|
||||
const userDomainApp = 'user:domain:app:' + user + ':' + app;
|
||||
const domainKeys = await redis.get(userDomainApp);
|
||||
if (domainKeys) {
|
||||
const domainKeysList = JSON.parse(domainKeys);
|
||||
domainKeysList.forEach(async (domain: string) => {
|
||||
await redis.del('domain:' + domain);
|
||||
});
|
||||
}
|
||||
await redis.del(userDomainApp);
|
||||
|
||||
// 删除所有文件
|
||||
deleteUserAppFiles(user, app);
|
||||
}
|
||||
fileCheck(file: string) {
|
||||
return checkFileExistsSync(file);
|
||||
}
|
||||
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 === 'oss') {
|
||||
let serverPath = new URL(resources).href;
|
||||
let hasIndexHtml = false;
|
||||
// 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 (file.name === 'index.html') {
|
||||
hasIndexHtml = true;
|
||||
}
|
||||
// 检查目录是否存在,如果不存在则创建
|
||||
if (!checkFileExistsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true }); // 递归创建目录
|
||||
}
|
||||
const downloadURL = wrapperResources(serverPath, file.path);
|
||||
// 下载文件到 destFile
|
||||
await downloadFile(downloadURL, destFile);
|
||||
const etag = nanoid();
|
||||
newFiles.push({
|
||||
name: file.name,
|
||||
path: destFile.replace(fileStore, '') + '||' + etag,
|
||||
});
|
||||
}
|
||||
if (!hasIndexHtml) {
|
||||
newFiles.push({
|
||||
name: 'index.html',
|
||||
path: path.join(uploadFiles, 'index.html'),
|
||||
});
|
||||
fs.writeFileSync(path.join(uploadFiles, 'index.html'), JSON.stringify(files), {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (err.code === 'ENOENT') {
|
||||
// 文件不存在
|
||||
} else {
|
||||
console.error('deleteUserAppFiles', err);
|
||||
}
|
||||
}
|
||||
// console.log('deleteUserAppFiles', res);
|
||||
};
|
||||
|
||||
async function downloadFile(fileUrl: string, destFile: string) {
|
||||
if (fileUrl.startsWith(minioResources)) {
|
||||
await downloadFileFromMinio(fileUrl, destFile);
|
||||
return;
|
||||
}
|
||||
console.log('destFile', destFile, 'fileUrl', fileUrl);
|
||||
const res = await fetch(fileUrl);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch ${fileUrl}: ${res.statusText}`);
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
31
src/modules/user-app/user-home.ts
Normal file
31
src/modules/user-app/user-home.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import http from 'http';
|
||||
import { getLoginUser } from '@/modules/auth.ts';
|
||||
import { getUserConfig } from '@/modules/fm-manager/index.ts';
|
||||
|
||||
/**
|
||||
* 重定向到用户首页
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const rediretHome = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
const user = await getLoginUser(req);
|
||||
if (!user?.token) {
|
||||
res.writeHead(302, { Location: '/root/login/' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
let redirectURL = '/root/center/';
|
||||
try {
|
||||
const token = user.token;
|
||||
const resConfig = await getUserConfig(token);
|
||||
if (resConfig.code === 200) {
|
||||
const configData = resConfig.data?.data as any;
|
||||
redirectURL = configData?.redirectURL || redirectURL;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('get resConfig user.json', error);
|
||||
} finally {
|
||||
res.writeHead(302, { Location: redirectURL });
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user