Compare commits

...

26 Commits

Author SHA1 Message Date
266b7b33de Refactor config management to use Drizzle ORM
- Replaced Sequelize with Drizzle ORM in config-related routes and models.
- Updated database queries to use Drizzle's syntax for selecting, inserting, updating, and deleting configurations.
- Removed the ConfigModel class and replaced it with direct database interactions.
- Introduced nanoid for generating unique IDs for new configurations.
- Added new routes for managing marks, including CRUD operations and versioning.
- Implemented transaction handling for critical operations in the MarkModel.
- Enhanced error handling and validation in routes.
2026-02-05 16:31:11 +08:00
5200cf4c38 修复用户应用键的分隔符,从 '-' 更改为 '--',以保持一致性并优化 WebSocket 连接管理 2026-02-05 14:08:45 +08:00
bf436f05e3 优化 WebSocket 连接管理,确保在注册新连接时关闭旧连接 2026-02-05 13:38:55 +08:00
bd7525efb0 添加心跳机制以保持 WebSocket 连接,优化连接关闭时的资源清理 2026-02-05 04:58:28 +08:00
f616045625 优化用户代理逻辑,移除非管理员用户的敏感数据 2026-02-05 04:50:54 +08:00
a51d04341e 添加 @kevisual/api 依赖,更新 WebSocket 消息发送逻辑,支持上下文参数 2026-02-05 04:06:34 +08:00
7bbefd8a4a 添加自动检测最新版本功能,更新应用信息时支持检测参数 2026-02-05 01:07:44 +08:00
db5c5a89b3 clear 2026-02-04 19:48:02 +08:00
86d4c7f75b 移除不再支持的文件扩展名 '.mjs' 从文本内容类型列表中 2026-02-04 19:45:01 +08:00
cbc9b54284 update 2026-02-04 03:08:53 +08:00
b1d3ca241c 优化 token 处理逻辑,统一过期时间字段命名 2026-02-03 17:09:09 +08:00
158dd9e85c Refactor AI proxy error handling and remove deprecated upload and event routes
- Updated `getAiProxy` function to return a JSON response for missing objects when the user is the owner.
- Removed the `upload.ts`, `event.ts`, and related middleware files to streamline the codebase.
- Cleaned up `handle-request.ts` and `index.ts` by removing unused imports and routes.
- Deleted chunk upload handling and related utility functions to simplify resource management.
- Enhanced app manager list functionality to support app creation if not found.
2026-02-02 18:06:31 +08:00
82e3392b36 优化 token 处理逻辑,统一 createCookie 调用格式,返回 token 过期时间 2026-02-02 17:02:20 +08:00
a0f0f65d20 update 2026-02-01 19:26:44 +08:00
337abd2bc3 temp 2026-02-01 16:04:03 +08:00
32167faf67 重构 postProxy 函数,移动到新文件 ai-proxy-chunk/post-proxy.ts,优化文件上传处理逻辑 2026-02-01 14:33:26 +08:00
82c9b834e9 更新依赖项,添加 flowme 插入触发器和监听器;重构数据库连接管理;优化用户路由和 SSE 处理 2026-02-01 03:58:40 +08:00
7c61bd3ac5 添加 flowme 和 flowme-channel 路由,支持增删改查功能;更新 schema 以包含相关字段 2026-01-31 18:27:21 +08:00
eab14b9fe3 更新依赖项 commander 到版本 14.0.3;调整导入路径以使用 S3 模块;添加成功移动和备份用户的日志输出 2026-01-31 14:16:54 +08:00
296286bdaf Refactor code structure for improved readability and maintainability 2026-01-31 05:15:25 +08:00
6100e9833d Refactor storage integration from MinIO to S3
- Removed MinIO client and related imports from various modules.
- Introduced S3 client and OSS integration for object storage.
- Updated all references to MinIO methods with corresponding S3 methods.
- Added new flowme table schema to the database.
- Adjusted upload and download routes to utilize S3 for file operations.
- Removed obsolete MinIO-related files and routes.
- Ensured compatibility with existing application logic while transitioning to S3.
2026-01-31 05:12:56 +08:00
08023d6878 优化 postProxy 函数以支持从请求头获取文件大小;添加 renameProxy 函数以处理文件重命名;修复 isLocalhost 函数的格式问题 2026-01-30 17:26:35 +08:00
c3624a59de 优化 auth 中间件的日志输出;注释掉未使用的路由定义;更新 page-proxy-app 路由的描述和中间件 2026-01-26 20:53:51 +08:00
c4e5668b29 update 2026-01-26 18:52:41 +08:00
27f170ae2b 更新 package.json 版本号至 0.0.12;优化 isLocalhost 函数以处理空主机名;修复 handleRequest 中对 dns.hostName 的访问;在轻代码列表中添加 code 字段;更新示例路由描述 2026-01-26 04:24:00 +08:00
3464bd240b 更新数据库配置,添加默认时间戳,优化轻代码路由处理,新增示例代码文件 2026-01-26 03:46:32 +08:00
74 changed files with 4491 additions and 1892 deletions

View File

@@ -0,0 +1,150 @@
---
name: create-routes
description: 创建路由例子模板代码
---
# 创建路由例子模板代码
app是自定义@kevisual/router的一个APP
1. 一般来说修改path和对应的schema表就可以快速创建对应的增删改查接口。
2. 根据需要,每一个功能需要添加对应的描述
3. 根据需要对应schema表的字段进行修改代码
示例:
```ts
import { desc, eq, count, or, like, and } from 'drizzle-orm';
import { schema, app, db } from '@/app.ts'
app.route({
path: 'prompts',
key: 'list',
middleware: ['auth'],
description: '获取提示词列表',
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const uid = tokenUser.id;
const { page = 1, pageSize = 20, search, sort = 'DESC' } = ctx.query || {};
const offset = (page - 1) * pageSize;
const orderByField = sort === 'ASC' ? schema.prompts.updatedAt : desc(schema.prompts.updatedAt);
let whereCondition = eq(schema.prompts.uid, uid);
if (search) {
whereCondition = and(
eq(schema.prompts.uid, uid),
or(
like(schema.prompts.title, `%${search}%`),
like(schema.prompts.summary, `%${search}%`)
)
);
}
const [list, totalCount] = await Promise.all([
db.select()
.from(schema.prompts)
.where(whereCondition)
.limit(pageSize)
.offset(offset)
.orderBy(orderByField),
db.select({ count: count() })
.from(schema.prompts)
.where(whereCondition)
]);
ctx.body = {
list,
pagination: {
page,
current: page,
pageSize,
total: totalCount[0]?.count || 0,
},
};
return ctx;
}).addTo(app);
const promptUpdate = `创建或更新一个提示词, 参数定义:
title: 提示词标题, 必填
description: 描述, 选填
summary: 摘要, 选填
tags: 标签, 数组, 选填
link: 链接, 选填
data: 数据, 对象, 选填
parents: 父级ID数组, 选填
`;
app.route({
path: 'prompts',
key: 'update',
middleware: ['auth'],
description: promptUpdate,
}).define(async (ctx) => {
const { id, uid, updatedAt, ...rest } = ctx.query.data || {};
const tokenUser = ctx.state.tokenUser;
let prompt;
if (!id) {
prompt = await db.insert(schema.prompts).values({
title: rest.title,
description: rest.description,
...rest,
uid: tokenUser.id,
}).returning();
} else {
const existing = await db.select().from(schema.prompts).where(eq(schema.prompts.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '没有找到对应的提示词');
}
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限更新该提示词');
}
prompt = await db.update(schema.prompts).set({
...rest,
}).where(eq(schema.prompts.id, id)).returning();
}
ctx.body = prompt;
}).addTo(app);
app.route({
path: 'prompts',
key: 'delete',
middleware: ['auth'],
description: '删除提示词, 参数: id 提示词ID',
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query.data || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}
const existing = await db.select().from(schema.prompts).where(eq(schema.prompts.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '没有找到对应的提示词');
}
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限删除该提示词');
}
await db.delete(schema.prompts).where(eq(schema.prompts.id, id));
ctx.body = { success: true };
}).addTo(app);
app.route({
path: 'prompts',
key: 'get',
middleware: ['auth'],
description: '获取单个提示词, 参数: id 提示词ID',
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query.data || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}
const existing = await db.select().from(schema.prompts).where(eq(schema.prompts.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '没有找到对应的提示词');
}
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限查看该提示词');
}
ctx.body = existing[0];
}).addTo(app);
```

View File

@@ -4,3 +4,10 @@ code center
``` ```
unzip -x app.config.json5 unzip -x app.config.json5
``` ```
```
"@kevisual/oss": "file:/home/ubuntu/kevisual/dev3/kevisual-oss",
```
```
TODO: /home/ubuntu/kevisual/code-center/src/routes/file/module/get-minio-list.ts
```

View File

@@ -1,6 +1,8 @@
import { useConfig } from '@kevisual/use-config/context';
import type { Config } from 'drizzle-kit'; import type { Config } from 'drizzle-kit';
import 'dotenv/config'; const config = useConfig();
const url = process.env.DATABASE_URL!; const url = config.DATABASE_URL!;
console.log('Drizzle config using database url:', url);
export default { export default {
schema: './src/db/schema.ts', schema: './src/db/schema.ts',

View File

@@ -1,6 +1,6 @@
{ {
"name": "@kevisual/code-center", "name": "@kevisual/code-center",
"version": "0.0.11", "version": "0.0.12",
"description": "code center", "description": "code center",
"type": "module", "type": "module",
"main": "index.js", "main": "index.js",
@@ -47,22 +47,23 @@
], ],
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@kevisual/ai": "^0.0.22", "@kevisual/ai": "^0.0.24",
"@kevisual/auth": "^2.0.3", "@kevisual/auth": "^2.0.3",
"@kevisual/query": "^0.0.38", "@kevisual/js-filter": "^0.0.5",
"@kevisual/query": "^0.0.39",
"@types/busboy": "^1.5.4", "@types/busboy": "^1.5.4",
"@types/send": "^1.2.1", "@types/send": "^1.2.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"bullmq": "^5.67.0", "bullmq": "^5.67.2",
"busboy": "^1.6.0", "busboy": "^1.6.0",
"commander": "^14.0.2", "commander": "^14.0.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"eventemitter3": "^5.0.4", "eventemitter3": "^5.0.4",
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
"minio": "^8.0.6", "minio": "^8.0.6",
"pg": "^8.17.2", "pg": "^8.18.0",
"pm2": "^6.0.14", "pm2": "^6.0.14",
"send": "^1.2.1", "send": "^1.2.1",
"sequelize": "^6.37.7", "sequelize": "^6.37.7",
@@ -71,26 +72,29 @@
"zod-to-json-schema": "^3.25.1" "zod-to-json-schema": "^3.25.1"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.981.0",
"@kevisual/api": "^0.0.44",
"@kevisual/code-center-module": "0.0.24", "@kevisual/code-center-module": "0.0.24",
"@kevisual/context": "^0.0.4", "@kevisual/context": "^0.0.4",
"@kevisual/file-listener": "^0.0.2", "@kevisual/file-listener": "^0.0.2",
"@kevisual/local-app-manager": "0.1.32", "@kevisual/local-app-manager": "0.1.32",
"@kevisual/logger": "^0.0.4", "@kevisual/logger": "^0.0.4",
"@kevisual/oss": "0.0.16", "@kevisual/oss": "0.0.19",
"@kevisual/permission": "^0.0.3", "@kevisual/permission": "^0.0.4",
"@kevisual/router": "0.0.60", "@kevisual/router": "0.0.70",
"@kevisual/types": "^0.0.12", "@kevisual/types": "^0.0.12",
"@kevisual/use-config": "^1.0.28", "@kevisual/use-config": "^1.0.30",
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.8",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.0.10", "@types/node": "^25.2.0",
"@types/pg": "^8.16.0",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"convex": "^1.31.6", "convex": "^1.31.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
@@ -101,7 +105,7 @@
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"nodemon": "^3.1.11", "nodemon": "^3.1.11",
"p-queue": "^9.1.0", "p-queue": "^9.1.0",
"pg": "^8.17.2", "pg": "^8.18.0",
"pm2": "^6.0.14", "pm2": "^6.0.14",
"semver": "^7.7.3", "semver": "^7.7.3",
"sequelize": "^6.37.7", "sequelize": "^6.37.7",
@@ -117,5 +121,5 @@
"better-sqlite3" "better-sqlite3"
] ]
}, },
"packageManager": "pnpm@10.28.1" "packageManager": "pnpm@10.28.2"
} }

1487
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
import { App } from '@kevisual/router'; import { App } from '@kevisual/router';
import * as redisLib from './modules/redis.ts'; import * as redisLib from './modules/redis.ts';
import * as minioLib from './modules/minio.ts';
import * as sequelizeLib from './modules/sequelize.ts'; import * as sequelizeLib from './modules/sequelize.ts';
import { useContextKey } from '@kevisual/context'; import { useContextKey } from '@kevisual/context';
import { SimpleRouter } from '@kevisual/router/simple'; import { SimpleRouter } from '@kevisual/router/simple';
import { OssBase } from '@kevisual/oss/services'; import { s3Client, oss as s3Oss } from './modules/s3.ts';
import { BailianProvider } from '@kevisual/ai'; import { BailianProvider } from '@kevisual/ai';
import * as schema from './db/schema.ts'; import * as schema from './db/schema.ts';
import { drizzle } from 'drizzle-orm/node-postgres';
import { config } from './modules/config.ts' import { config } from './modules/config.ts'
import { db } from './modules/db.ts'
export const router = useContextKey('router', () => new SimpleRouter()); export const router = useContextKey('router', () => new SimpleRouter());
export const runtime = useContextKey('runtime', () => { export const runtime = useContextKey('runtime', () => {
return { return {
@@ -18,21 +17,13 @@ export const runtime = useContextKey('runtime', () => {
}); });
export const oss = useContextKey( export const oss = useContextKey(
'oss', 'oss',
() => () => s3Oss,
new OssBase({
client: minioLib.minioClient,
bucketName: minioLib.bucketName,
prefix: '',
}),
); );
export { s3Client }
export const redis = useContextKey('redis', () => redisLib.redis); export const redis = useContextKey('redis', () => redisLib.redis);
export const subscriber = useContextKey('subscriber', () => redisLib.subscriber); export const subscriber = useContextKey('subscriber', () => redisLib.subscriber);
export const minioClient = useContextKey('minioClient', () => minioLib.minioClient);
export const sequelize = useContextKey('sequelize', () => sequelizeLib.sequelize); export const sequelize = useContextKey('sequelize', () => sequelizeLib.sequelize);
export const db = useContextKey('db', () => { export { db };
const db = drizzle(config.DATABASE_URL || '');
return db;
})
const init = () => { const init = () => {
return new App({ return new App({
serverOptions: { serverOptions: {

View File

@@ -62,7 +62,13 @@ export class User extends Model {
oauthUser.orgId = id; oauthUser.orgId = id;
} }
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...expand }); const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...expand });
return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken }; return {
accessToken: token.accessToken,
refreshToken: token.refreshToken,
token: token.accessToken,
refreshTokenExpiresIn: token.refreshTokenExpiresIn,
accessTokenExpiresIn: token.accessTokenExpiresIn,
};
} }
/** /**
* 验证token * 验证token
@@ -232,6 +238,17 @@ export class User extends Model {
async expireOrgs() { async expireOrgs() {
await redis.del(`user:${this.id}:orgs`); await redis.del(`user:${this.id}:orgs`);
} }
static async getUserNameById(id: string) {
const redisName = await redis.get(`user:id:${id}:name`);
if (redisName) {
return redisName;
}
const user = await User.findByPk(id);
if (user?.username) {
await redis.set(`user:id:${id}:name`, user.username, 'EX', 60 * 60); // 1 hour
}
return user?.username;
}
} }
export type SyncOpts = { export type SyncOpts = {
alter?: boolean; alter?: boolean;

View File

@@ -70,9 +70,16 @@ interface Store<T> {
expire: (key: string, ttl?: number) => Promise<void>; expire: (key: string, ttl?: number) => Promise<void>;
delObject: (value?: T) => Promise<void>; delObject: (value?: T) => Promise<void>;
keys: (key?: string) => Promise<string[]>; keys: (key?: string) => Promise<string[]>;
setToken: (value: { accessToken: string; refreshToken: string; value?: T }, opts?: StoreSetOpts) => Promise<void>; setToken: (value: { accessToken: string; refreshToken: string; value?: T }, opts?: StoreSetOpts) => Promise<TokenData>;
delKeys: (keys: string[]) => Promise<number>; delKeys: (keys: string[]) => Promise<number>;
} }
type TokenData = {
accessToken: string;
accessTokenExpiresIn?: number;
refreshToken?: string;
refreshTokenExpiresIn?: number;
}
export class RedisTokenStore implements Store<OauthUser> { export class RedisTokenStore implements Store<OauthUser> {
redis: Redis; redis: Redis;
private prefix: string = 'oauth:'; private prefix: string = 'oauth:';
@@ -131,7 +138,7 @@ export class RedisTokenStore implements Store<OauthUser> {
await this.del(userPrefix + ':token:' + accessToken); await this.del(userPrefix + ':token:' + accessToken);
} }
} }
async setToken(data: { accessToken: string; refreshToken: string; value?: OauthUser }, opts?: StoreSetOpts) { async setToken(data: { accessToken: string; refreshToken: string; value?: OauthUser }, opts?: StoreSetOpts): Promise<TokenData> {
const { accessToken, refreshToken, value } = data; const { accessToken, refreshToken, value } = data;
let userPrefix = 'user:' + value?.id; let userPrefix = 'user:' + value?.id;
if (value?.orgId) { if (value?.orgId) {
@@ -163,14 +170,20 @@ export class RedisTokenStore implements Store<OauthUser> {
await this.set(accessToken, JSON.stringify(value), expire); await this.set(accessToken, JSON.stringify(value), expire);
await this.set(userPrefix + ':token:' + accessToken, accessToken, expire); await this.set(userPrefix + ':token:' + accessToken, accessToken, expire);
let refreshTokenExpiresIn = Math.min(expire * 7, 60 * 60 * 24 * 30, 60 * 60 * 24 * 365); // 最大为一年
if (refreshToken) { if (refreshToken) {
let refreshTokenExpire = Math.min(expire * 7, 60 * 60 * 24 * 30, 60 * 60 * 24 * 365); // 最大为一年
// 小于7天, 则设置为7天 // 小于7天, 则设置为7天
if (refreshTokenExpire < 60 * 60 * 24 * 7) { if (refreshTokenExpiresIn < 60 * 60 * 24 * 7) {
refreshTokenExpire = 60 * 60 * 24 * 7; refreshTokenExpiresIn = 60 * 60 * 24 * 7;
} }
await this.set(refreshToken, JSON.stringify(value), refreshTokenExpire); await this.set(refreshToken, JSON.stringify(value), refreshTokenExpiresIn);
await this.set(userPrefix + ':refreshToken:' + refreshToken, refreshToken, refreshTokenExpire); await this.set(userPrefix + ':refreshToken:' + refreshToken, refreshToken, refreshTokenExpiresIn);
}
return {
accessToken,
accessTokenExpiresIn: expire,
refreshToken,
refreshTokenExpiresIn: refreshTokenExpiresIn,
} }
} }
async delKeys(keys: string[]) { async delKeys(keys: string[]) {
@@ -206,10 +219,7 @@ export class OAuth<T extends OauthUser> {
async generateToken( async generateToken(
user: T, user: T,
expandOpts?: StoreSetOpts, expandOpts?: StoreSetOpts,
): Promise<{ ): Promise<TokenData> {
accessToken: string;
refreshToken?: string;
}> {
// 拥有refreshToken 为 true 时accessToken 为 st_ 开头refreshToken 为 rk_开头 // 拥有refreshToken 为 true 时accessToken 为 st_ 开头refreshToken 为 rk_开头
// 意思是secretToken 和 secretKey的缩写 // 意思是secretToken 和 secretKey的缩写
const accessToken = expandOpts?.hasRefreshToken ? 'st_' + randomId32() : 'sk_' + randomId64(); const accessToken = expandOpts?.hasRefreshToken ? 'st_' + randomId32() : 'sk_' + randomId64();
@@ -227,9 +237,9 @@ export class OAuth<T extends OauthUser> {
user.oauthExpand.refreshToken = refreshToken; user.oauthExpand.refreshToken = refreshToken;
} }
} }
await this.store.setToken({ accessToken, refreshToken, value: user }, expandOpts); const tokenData = await this.store.setToken({ accessToken, refreshToken, value: user }, expandOpts);
return { accessToken, refreshToken }; return tokenData;
} }
async saveSecretKey(oauthUser: T, secretKey: string, opts?: StoreSetOpts) { async saveSecretKey(oauthUser: T, secretKey: string, opts?: StoreSetOpts) {
// 生成一个secretKey // 生成一个secretKey

View File

@@ -10,8 +10,8 @@ export const testPromptTools = pgTable("TestPromptTools", {
args: jsonb().notNull(), args: jsonb().notNull(),
process: jsonb().notNull(), process: jsonb().notNull(),
type: varchar({ length: 255 }).notNull(), type: varchar({ length: 255 }).notNull(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
}); });
export const aiAgent = pgTable("ai_agent", { export const aiAgent = pgTable("ai_agent", {
@@ -22,8 +22,8 @@ export const aiAgent = pgTable("ai_agent", {
temperature: doublePrecision(), temperature: doublePrecision(),
cache: varchar({ length: 255 }), cache: varchar({ length: 255 }),
cacheName: varchar({ length: 255 }), cacheName: varchar({ length: 255 }),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
model: varchar({ length: 255 }).notNull(), model: varchar({ length: 255 }).notNull(),
data: json().default({}), data: json().default({}),
status: varchar({ length: 255 }).default('open'), status: varchar({ length: 255 }).default('open'),
@@ -43,8 +43,8 @@ export const appsTrades = pgTable("apps_trades", {
type: varchar({ length: 255 }).default('alipay').notNull(), type: varchar({ length: 255 }).default('alipay').notNull(),
data: jsonb().default({ "list": [] }), data: jsonb().default({ "list": [] }),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
}, (table) => [ }, (table) => [
unique("apps_trades_out_trade_no_key").on(table.outTradeNo), unique("apps_trades_out_trade_no_key").on(table.outTradeNo),
@@ -54,8 +54,8 @@ export const cfOrgs = pgTable("cf_orgs", {
id: uuid().primaryKey().notNull(), id: uuid().primaryKey().notNull(),
username: varchar({ length: 255 }).notNull(), username: varchar({ length: 255 }).notNull(),
users: jsonb().default([]), users: jsonb().default([]),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
description: varchar({ length: 255 }), description: varchar({ length: 255 }),
}, (table) => [ }, (table) => [
@@ -70,8 +70,8 @@ export const cfRouterCode = pgTable("cf_router_code", {
project: varchar({ length: 255 }).default('default'), project: varchar({ length: 255 }).default('default'),
code: text().default(''), code: text().default(''),
type: enumCfRouterCodeType().default('route'), type: enumCfRouterCodeType().default('route'),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
middleware: varchar({ length: 255 }).array().default(["RRAY[]::character varying[])::character varying(25"]), middleware: varchar({ length: 255 }).array().default(["RRAY[]::character varying[])::character varying(25"]),
next: varchar({ length: 255 }).default(''), next: varchar({ length: 255 }).default(''),
exec: text().default(''), exec: text().default(''),
@@ -86,8 +86,8 @@ export const cfUser = pgTable("cf_user", {
password: varchar({ length: 255 }), password: varchar({ length: 255 }),
salt: varchar({ length: 255 }), salt: varchar({ length: 255 }),
needChangePassword: boolean().default(false), needChangePassword: boolean().default(false),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
description: text(), description: text(),
data: jsonb().default({}), data: jsonb().default({}),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
@@ -111,8 +111,8 @@ export const cfUserSecrets = pgTable("cf_user_secrets", {
userId: uuid(), userId: uuid(),
data: jsonb().default({}), data: jsonb().default({}),
orgId: uuid(), orgId: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
}); });
export const chatHistories = pgTable("chat_histories", { export const chatHistories = pgTable("chat_histories", {
@@ -123,8 +123,8 @@ export const chatHistories = pgTable("chat_histories", {
root: boolean().default(false), root: boolean().default(false),
show: boolean().default(true), show: boolean().default(true),
uid: varchar({ length: 255 }), uid: varchar({ length: 255 }),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
role: varchar({ length: 255 }).default('user'), role: varchar({ length: 255 }).default('user'),
}); });
@@ -135,8 +135,8 @@ export const chatPrompts = pgTable("chat_prompts", {
data: json(), data: json(),
key: varchar({ length: 255 }).default('').notNull(), key: varchar({ length: 255 }).default('').notNull(),
uid: varchar({ length: 255 }), uid: varchar({ length: 255 }),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
}); });
@@ -146,8 +146,8 @@ export const chatSessions = pgTable("chat_sessions", {
chatPromptId: uuid(), chatPromptId: uuid(),
type: varchar({ length: 255 }).default('production'), type: varchar({ length: 255 }).default('production'),
uid: varchar({ length: 255 }), uid: varchar({ length: 255 }),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
title: varchar({ length: 255 }).default(''), title: varchar({ length: 255 }).default(''),
key: varchar({ length: 255 }), key: varchar({ length: 255 }),
}); });
@@ -159,8 +159,8 @@ export const fileSync = pgTable("file_sync", {
stat: jsonb().default({}), stat: jsonb().default({}),
data: jsonb().default({}), data: jsonb().default({}),
checkedAt: timestamp({ withTimezone: true, mode: 'string' }), checkedAt: timestamp({ withTimezone: true, mode: 'string' }),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
}, (table) => [ }, (table) => [
index("file_sync_name_idx").using("btree", table.name.asc().nullsLast()), index("file_sync_name_idx").using("btree", table.name.asc().nullsLast()),
]); ]);
@@ -177,8 +177,8 @@ export const kvAiChatHistory = pgTable("kv_ai_chat_history", {
completionTokens: integer("completion_tokens").default(0), completionTokens: integer("completion_tokens").default(0),
data: jsonb().default({}), data: jsonb().default({}),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
version: integer().default(0), version: integer().default(0),
type: varchar({ length: 255 }).default('keep').notNull(), type: varchar({ length: 255 }).default('keep').notNull(),
}); });
@@ -189,8 +189,8 @@ export const kvApp = pgTable("kv_app", {
version: varchar({ length: 255 }).default(''), version: varchar({ length: 255 }).default(''),
key: varchar({ length: 255 }), key: varchar({ length: 255 }),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
title: varchar({ length: 255 }).default(''), title: varchar({ length: 255 }).default(''),
description: varchar({ length: 255 }).default(''), description: varchar({ length: 255 }).default(''),
@@ -208,8 +208,8 @@ export const kvAppDomain = pgTable("kv_app_domain", {
domain: varchar({ length: 255 }).notNull(), domain: varchar({ length: 255 }).notNull(),
appId: varchar({ length: 255 }), appId: varchar({ length: 255 }),
uid: varchar({ length: 255 }), uid: varchar({ length: 255 }),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
data: jsonb(), data: jsonb(),
status: varchar({ length: 255 }).default('running').notNull(), status: varchar({ length: 255 }).default('running').notNull(),
@@ -222,8 +222,8 @@ export const kvAppList = pgTable("kv_app_list", {
data: json().default({}), data: json().default({}),
version: varchar({ length: 255 }).default(''), version: varchar({ length: 255 }).default(''),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
key: varchar({ length: 255 }), key: varchar({ length: 255 }),
status: varchar({ length: 255 }).default('running'), status: varchar({ length: 255 }).default('running'),
@@ -237,24 +237,23 @@ export const kvConfig = pgTable("kv_config", {
tags: jsonb().default([]), tags: jsonb().default([]),
data: jsonb().default({}), data: jsonb().default({}),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
hash: text().default(''), hash: text().default(''),
}); });
export const kvContainer = pgTable("kv_container", { export const kvContainer = pgTable("kv_light_code", {
id: uuid().primaryKey().notNull(), id: uuid().primaryKey().notNull().defaultRandom(),
title: text().default(''), title: text().default(''),
description: text().default(''), description: text().default(''),
type: varchar({ length: 255 }).default('render-js'), type: text().default('render-js'),
code: text().default(''), code: text().default(''),
data: json().default({}), data: jsonb().default({}),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp('updatedAt').notNull().defaultNow(),
uid: uuid(), uid: uuid(),
tags: json().default([]), tags: jsonb().default([]),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
hash: text().default(''), hash: text().default(''),
}); });
@@ -263,8 +262,8 @@ export const kvGithub = pgTable("kv_github", {
title: varchar({ length: 255 }).default(''), title: varchar({ length: 255 }).default(''),
githubToken: varchar({ length: 255 }).default(''), githubToken: varchar({ length: 255 }).default(''),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
}); });
@@ -277,8 +276,8 @@ export const kvPackages = pgTable("kv_packages", {
publish: jsonb().default({}), publish: jsonb().default({}),
expand: jsonb().default({}), expand: jsonb().default({}),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
}); });
@@ -289,8 +288,8 @@ export const kvPage = pgTable("kv_page", {
type: varchar({ length: 255 }).default(''), type: varchar({ length: 255 }).default(''),
data: json().default({}), data: json().default({}),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
publish: json().default({}), publish: json().default({}),
}); });
@@ -304,8 +303,8 @@ export const kvResource = pgTable("kv_resource", {
version: varchar({ length: 255 }).default('0.0.0'), version: varchar({ length: 255 }).default('0.0.0'),
data: json().default({}), data: json().default({}),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
}); });
@@ -317,8 +316,8 @@ export const kvVip = pgTable("kv_vip", {
startDate: timestamp({ withTimezone: true, mode: 'string' }), startDate: timestamp({ withTimezone: true, mode: 'string' }),
endDate: timestamp({ withTimezone: true, mode: 'string' }), endDate: timestamp({ withTimezone: true, mode: 'string' }),
data: jsonb().default({}), data: jsonb().default({}),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
title: text().default('').notNull(), title: text().default('').notNull(),
description: text().default('').notNull(), description: text().default('').notNull(),
@@ -335,8 +334,8 @@ export const microAppsUpload = pgTable("micro_apps_upload", {
share: boolean().default(false), share: boolean().default(false),
uname: varchar({ length: 255 }).default(''), uname: varchar({ length: 255 }).default(''),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
}); });
export const microMark = pgTable("micro_mark", { export const microMark = pgTable("micro_mark", {
@@ -347,8 +346,8 @@ export const microMark = pgTable("micro_mark", {
data: jsonb().default({}), data: jsonb().default({}),
uname: varchar({ length: 255 }).default(''), uname: varchar({ length: 255 }).default(''),
uid: uuid(), uid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
cover: text().default(''), cover: text().default(''),
thumbnail: text().default(''), thumbnail: text().default(''),
link: text().default(''), link: text().default(''),
@@ -380,8 +379,8 @@ export const workShareMark = pgTable("work_share_mark", {
markedAt: timestamp({ withTimezone: true, mode: 'string' }), markedAt: timestamp({ withTimezone: true, mode: 'string' }),
uid: uuid(), uid: uuid(),
puid: uuid(), puid: uuid(),
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
deletedAt: timestamp({ withTimezone: true, mode: 'string' }), deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
}); });
@@ -494,3 +493,46 @@ export const queryViews = pgTable("query_views", {
index('query_views_uid_idx').using('btree', table.uid.asc().nullsLast()), index('query_views_uid_idx').using('btree', table.uid.asc().nullsLast()),
index('query_title_idx').using('btree', table.title.asc().nullsLast()), index('query_title_idx').using('btree', table.title.asc().nullsLast()),
]); ]);
export const flowme = pgTable("flowme", {
id: uuid().primaryKey().notNull().defaultRandom(),
uid: uuid(),
title: text('title').default(''),
description: text('description').default(''),
tags: jsonb().default([]),
link: text('link').default(''),
data: jsonb().default({}),
channelId: uuid().references(() => flowmeChannels.id, { onDelete: 'set null' }),
type: text('type').default(''),
source: text('source').default(''),
importance: integer('importance').default(0), // 重要性等级
isArchived: boolean('isArchived').default(false), // 是否归档
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
}, (table) => [
index('flowme_uid_idx').using('btree', table.uid.asc().nullsLast()),
index('flowme_title_idx').using('btree', table.title.asc().nullsLast()),
index('flowme_channel_id_idx').using('btree', table.channelId.asc().nullsLast()),
]);
export const flowmeChannels = pgTable("flowme_channels", {
id: uuid().primaryKey().notNull().defaultRandom(),
uid: uuid(),
title: text('title').default(''),
key: text('key').default(''),
description: text('description').default(''),
tags: jsonb().default([]),
link: text('link').default(''),
data: jsonb().default({}),
color: text('color').default('#007bff'),
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
}, (table) => [
index('flowme_channels_uid_idx').using('btree', table.uid.asc().nullsLast()),
index('flowme_channels_key_idx').using('btree', table.key.asc().nullsLast()),
index('flowme_channels_title_idx').using('btree', table.title.asc().nullsLast()),
]);

View File

@@ -1,6 +1,6 @@
import { useConfig } from '@kevisual/use-config'; import { useConfig } from '@kevisual/use-config';
import { useFileStore } from '@kevisual/use-config'; import { useFileStore } from '@kevisual/use-config';
import { minioResources } from './minio.ts'; import { minioResources } from './s3.ts';
export const config = useConfig() as any; export const config = useConfig() as any;
export const port = config.PORT ? Number(config.PORT) : 4005; export const port = config.PORT ? Number(config.PORT) : 4005;

8
src/modules/db.ts Normal file
View File

@@ -0,0 +1,8 @@
import { useContextKey } from '@kevisual/context';
import { drizzle } from 'drizzle-orm/node-postgres';
import { config } from './config.ts'
export const db = useContextKey('db', () => {
const db = drizzle(config.DATABASE_URL || '');
return db;
})

View File

@@ -11,7 +11,6 @@ export const getTextContentType = (filePath: string, isFilePath = false) => {
'.env', '.env',
'.example', '.example',
'.log', '.log',
'.mjs',
'.map', '.map',
'.json5', '.json5',
'.pem', '.pem',

View File

@@ -0,0 +1,239 @@
import { IncomingMessage, ServerResponse } from 'http';
import busboy from 'busboy';
import fs from 'fs';
import path from 'path';
import { createWriteStream } from 'fs';
import { parseSearchValue } from '@kevisual/router/src/server/parse-body.ts';
import { pipeBusboy } from '../../pipe-busboy.ts';
import { ProxyOptions, getMetadata, getObjectName } from '../ai-proxy.ts';
import { fileIsExist, useFileStore } from '@kevisual/use-config';
import { getContentType } from '../../get-content-type.ts';
const cacheFilePath = useFileStore('cache-file', { needExists: true });
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);
}
console.log('postProxy', { hash, force, fileSize });
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,
},
defCharset: 'utf-8',
});
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 },
);
end({ success: true, name, info, isNew: true, hash, meta: meta?.metaData, statMeta }, '上传成功', 200);
} catch (error) {
console.log('postProxy upload error', 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);
});
pipeBusboy(req, res, bb);
};
export const postChunkProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
const oss = opts.oss;
const { objectName, isOwner } = await getObjectName(req);
if (!isOwner) {
return opts?.createNotFoundPage?.('no permission');
}
const _u = new URL(req.url, 'http://localhost');
const params = _u.searchParams;
const meta = parseSearchValue(params.get('meta'), { decode: true });
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' }));
};
const bb = busboy({
headers: req.headers,
limits: {
fileSize: 100 * 1024 * 1024, // 100MB
files: 1,
},
defCharset: 'utf-8',
});
const fields: any = {};
let filePromise: Promise<void> | null = null;
let tempPath = '';
let file: any = null;
bb.on('field', (fieldname, value) => {
fields[fieldname] = value;
});
bb.on('file', (_fieldname, fileStream, info) => {
const { filename, mimeType } = info;
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
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: decodedFilename,
mimetype: mimeType,
};
resolve();
});
writeStream.on('error', reject);
});
});
bb.on('finish', async () => {
if (filePromise) {
try {
await filePromise;
} catch (err) {
console.error(`File write error: ${err.message}`);
return end({ error: err }, '文件写入失败', 500);
}
}
const clearFiles = () => {
if (tempPath && fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
};
if (!file) {
clearFiles();
return end({ success: false }, '没有接收到文件', 400);
}
let { chunkIndex, totalChunks } = fields;
chunkIndex = parseInt(chunkIndex, 10);
totalChunks = parseInt(totalChunks, 10);
if (isNaN(chunkIndex) || isNaN(totalChunks) || totalChunks <= 0) {
clearFiles();
return end({ success: false }, 'chunkIndex 和 totalChunks 参数无效', 400);
}
const finalFilePath = path.join(cacheFilePath, `chunk-${objectName.replace(/[\/\.]/g, '-')}`);
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);
if (chunkIndex + 1 === totalChunks) {
try {
const metadata = {
...getMetadata(relativePath),
...meta,
'app-source': 'user-app',
};
if (fileIsExist(finalFilePath)) {
console.log('上传到对象存储', { objectName, metadata });
const content = fs.readFileSync(finalFilePath);
await oss.putObject(objectName, content, metadata);
console.log('上传到对象存储完成', { objectName });
fs.unlinkSync(finalFilePath);
}
return end({
success: true,
name: relativePath,
chunkIndex,
totalChunks,
objectName,
}, '分块上传完成', 200);
} catch (error) {
console.error('postChunkProxy upload error', error);
clearFiles();
if (fs.existsSync(finalFilePath)) {
fs.unlinkSync(finalFilePath);
}
return end({ error }, '上传失败', 500);
}
} else {
return end({
success: true,
chunkIndex,
totalChunks,
progress: ((chunkIndex + 1) / totalChunks) * 100,
}, '分块上传成功', 200);
}
});
writeStream.on('error', (err) => {
console.error('Write stream error', err);
clearFiles();
return end({ error: err }, '文件写入失败', 500);
});
});
bb.on('error', (err) => {
console.error('Busboy 错误:', err);
end({ error: err }, '文件解析失败', 500);
});
pipeBusboy(req, res, bb);
};

View File

@@ -1,4 +1,4 @@
import { bucketName, minioClient } from '@/modules/minio.ts'; import { oss } from '@/app.ts';
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import { filterKeys } from './http-proxy.ts'; import { filterKeys } from './http-proxy.ts';
import { getUserFromRequest } from '../utils.ts'; import { getUserFromRequest } from '../utils.ts';
@@ -11,7 +11,8 @@ import { parseSearchValue } from '@kevisual/router/src/server/parse-body.ts';
import { logger } from '@/modules/logger.ts'; import { logger } from '@/modules/logger.ts';
import { pipeBusboy } from '../pipe-busboy.ts'; import { pipeBusboy } from '../pipe-busboy.ts';
import { pipeMinioStream } from '../pipe.ts'; import { pipeMinioStream } from '../pipe.ts';
import { Readable } from 'stream';
import { postChunkProxy, postProxy } from './ai-proxy-chunk/post-proxy.ts'
type FileList = { type FileList = {
name: string; name: string;
prefix?: string; prefix?: string;
@@ -53,6 +54,14 @@ export const getFileList = async (list: any, opts?: { objectName: string; app: s
}); });
}; };
// import { logger } from '@/module/logger.ts'; // import { logger } from '@/module/logger.ts';
/**
* GET 处理 AI 代理请求
* 1. 如果是目录请求,返回目录列表
* 2. 如果是文件请求,返回文件流
*
* 如果是 stat
* 只返回对应的 stat 信息
*/
const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => { const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
const { createNotFoundPage } = opts; const { createNotFoundPage } = opts;
const _u = new URL(req.url, 'http://localhost'); const _u = new URL(req.url, 'http://localhost');
@@ -62,6 +71,7 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
const hash = params.get('hash'); const hash = params.get('hash');
let dir = !!params.get('dir'); let dir = !!params.get('dir');
const recursive = !!params.get('recursive'); const recursive = !!params.get('recursive');
const showStat = !!params.get('stat');
const { objectName, app, owner, loginUser, isOwner } = await getObjectName(req); const { objectName, app, owner, loginUser, isOwner } = await getObjectName(req);
if (!dir && _u.pathname.endsWith('/')) { if (!dir && _u.pathname.endsWith('/')) {
dir = true; // 如果是目录请求强制设置为true dir = true; // 如果是目录请求强制设置为true
@@ -88,10 +98,19 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
return true; return true;
} }
const stat = await oss.statObject(objectName); const stat = await oss.statObject(objectName);
if (!stat) { if (!stat && isOwner) {
createNotFoundPage('Invalid proxy url'); // createNotFoundPage('文件不存在');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
code: 404,
message: 'object not found',
}),
);
logger.debug('no stat', objectName, owner, req.url); logger.debug('no stat', objectName, owner, req.url);
return true; return true;
} else if (!stat && !isOwner) {
return createNotFoundPage('Invalid ai proxy url');
} }
const permissionInstance = new UserPermission({ permission: stat.metaData as Permission, owner: owner }); const permissionInstance = new UserPermission({ permission: stat.metaData as Permission, owner: owner });
const checkPermission = permissionInstance.checkPermissionSuccess({ const checkPermission = permissionInstance.checkPermissionSuccess({
@@ -102,6 +121,20 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
logger.info('no permission', checkPermission, loginUser, owner); logger.info('no permission', checkPermission, loginUser, owner);
return createNotFoundPage('no permission'); return createNotFoundPage('no permission');
} }
if (showStat) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
code: 200,
data: {
...stat,
metaData: filterKeys(stat.metaData, ['etag', 'size']),
},
}),
);
return true;
}
if (hash && stat.etag === hash) { if (hash && stat.etag === hash) {
res.writeHead(304); // not modified res.writeHead(304); // not modified
res.end('not modified'); res.end('not modified');
@@ -112,7 +145,8 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
const etag = stat.etag; const etag = stat.etag;
const lastModified = stat.lastModified.toISOString(); const lastModified = stat.lastModified.toISOString();
const objectStream = await minioClient.getObject(bucketName, objectName); const objectStream = await oss.getObject(objectName);
// const objectStream = await minioClient.getObject(bucketName, objectName);
const headers = { const headers = {
'Content-Length': contentLength, 'Content-Length': contentLength,
etag, etag,
@@ -124,8 +158,7 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
...headers, ...headers,
}); });
// objectStream.pipe(res, { end: true }); // objectStream.pipe(res, { end: true });
// @ts-ignore pipeMinioStream(objectStream.Body as Readable, res);
pipeMinioStream(objectStream, res);
return true; return true;
} catch (error) { } catch (error) {
console.error(`Proxy request error: ${error.message}`); console.error(`Proxy request error: ${error.message}`);
@@ -152,87 +185,24 @@ export const getMetadata = (pathname: string) => {
return meta; return meta;
}; };
export const postProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => { export const getObjectByPathname = (opts: {
const _u = new URL(req.url, 'http://localhost'); pathname: string,
version?: string,
const pathname = _u.pathname; }) => {
const oss = opts.oss; const [_, user, app] = opts.pathname.split('/');
const params = _u.searchParams; let prefix = '';
const force = !!params.get('force'); let replaceKey = '';
const hash = params.get('hash'); if (app === 'ai') {
const _fileSize: string = params.get('size'); const version = opts?.version || '1.0.0';
let fileSize: number | undefined = undefined; replaceKey = `/${user}/${app}/`;
if (_fileSize) { prefix = `${user}/${app}/${version}/`;
fileSize = parseInt(_fileSize, 10) } else {
replaceKey = `/${user}/${app}/`;
prefix = `${user}/`; // root/resources
} }
let meta = parseSearchValue(params.get('meta'), { decode: true }); let objectName = opts.pathname.replace(replaceKey, prefix);
if (!hash && !force) { return { prefix, replaceKey, objectName, user, app };
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,
},
defCharset: 'utf-8',
});
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);
});
pipeBusboy(req, res, bb);
};
export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?: boolean }) => { export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?: boolean }) => {
const _u = new URL(req.url, 'http://localhost'); const _u = new URL(req.url, 'http://localhost');
const pathname = decodeURIComponent(_u.pathname); const pathname = decodeURIComponent(_u.pathname);
@@ -252,7 +222,7 @@ export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?:
let loginUser: Awaited<ReturnType<typeof getLoginUser>> = null; let loginUser: Awaited<ReturnType<typeof getLoginUser>> = null;
if (checkOwner) { if (checkOwner) {
loginUser = await getLoginUser(req); loginUser = await getLoginUser(req);
logger.debug('getObjectName', loginUser, user, app); logger.debug('getObjectName', user, app);
isOwner = loginUser?.tokenUser?.username === owner; isOwner = loginUser?.tokenUser?.username === owner;
} }
return { return {
@@ -270,32 +240,162 @@ export const deleteProxy = async (req: IncomingMessage, res: ServerResponse, opt
if (!isOwner) { if (!isOwner) {
return opts?.createNotFoundPage?.('no permission'); 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' }));
};
try { try {
// 如果以 / 结尾,删除该前缀下的所有对象(文件夹)
if (objectName.endsWith('/')) {
const objects = await oss.listObjects<true>(objectName, { recursive: true });
if (objects.length > 0) {
for (const obj of objects) {
await oss.deleteObject(obj.name);
}
}
end({ success: true, objectName, deletedCount: objects.length }, 'delete success', 200);
} else {
await oss.deleteObject(objectName); await oss.deleteObject(objectName);
res.writeHead(200, { 'Content-Type': 'application/json' }); end({ success: true, objectName }, 'delete success', 200);
res.end(JSON.stringify({ success: true, message: 'delete success', objectName })); }
} catch (error) { } catch (error) {
logger.error('deleteProxy error', error); logger.error('deleteProxy error', error);
res.writeHead(500, { 'Content-Type': 'application/json' }); end({ success: false, error }, 'delete failed', 500);
res.end(JSON.stringify({ success: false, error: error }));
} }
}; };
type ProxyOptions = { export const renameProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
const { objectName, isOwner, user } = await getObjectName(req);
let oss = opts.oss;
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' }));
};
const _u = new URL(req.url, 'http://localhost');
let newName = _u.searchParams.get('newName');
if (!newName) {
return end({ success: false }, 'newName parameter required', 400);
}
// 解码 URL 编码的路径
newName = decodeURIComponent(newName);
if (!newName.startsWith('/')) {
newName = '/' + newName;
}
const newUrl = new URL(newName, 'http://localhost');
const version = _u.searchParams.get('version') || '1.0.0';
const newNamePath = newUrl.pathname;
// 确保 newName 有正确的前缀路径
const newObject = getObjectByPathname({ pathname: newNamePath, version });
const { user: newUser, objectName: newObjectName } = newObject;
if (newUser !== user) {
return end({ success: false }, '文件重命名只能在同一用户下进行', 400);
}
try {
const isDir = objectName.endsWith('/');
let copiedCount = 0;
if (isDir) {
// 重命名文件夹:复制所有对象到新前缀,然后删除原对象
const objects = await oss.listObjects<true>(objectName, { recursive: true });
for (const obj of objects) {
const oldKey = obj.name;
const newKey = oldKey.replace(objectName, newObjectName);
console.log('rename dir object', oldKey, newKey);
await oss.copyObject(oldKey, newKey);
copiedCount++;
}
for (const obj of objects) {
console.log('deleted object', obj.name);
await oss.deleteObject(obj.name);
}
} else {
// 重命名文件
await oss.copyObject(objectName, newObjectName);
await oss.deleteObject(objectName);
copiedCount = 1;
}
end({ success: true, objectName, newObjectName, copiedCount }, 'rename success', 200);
} catch (error) {
logger.error('renameProxy error', error);
end({ success: false, error }, 'rename failed', 500);
}
};
export const updateMetadataProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
const { objectName, isOwner } = await getObjectName(req);
let oss = opts.oss;
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' }));
};
const _u = new URL(req.url, 'http://localhost');
const params = _u.searchParams;
const metaParam = params.get('meta');
if (!metaParam) {
return end({ success: false }, 'meta parameter required', 400);
}
const meta = parseSearchValue(metaParam, { decode: true });
try {
const stat = await oss.statObject(objectName);
if (!stat) {
return end({ success: false }, 'object not found', 404);
}
const newMeta = {
"app-source": "user-app",
...meta,
};
console.log('update metadata', objectName, newMeta);
// 过滤掉包含无效字符的 keyS3 元数据头不支持某些字符)
const filteredMeta: Record<string, string> = {};
for (const [key, value] of Object.entries(newMeta)) {
if (/^[\w\-]+$/.test(key)) {
filteredMeta[key] = String(value);
}
}
await oss.replaceObject(objectName, filteredMeta);
end({ success: true, objectName, meta }, 'update metadata success', 200);
} catch (error) {
logger.error('updateMetadataProxy error', error);
end({ success: false, error }, 'update metadata failed', 500);
}
};
export type ProxyOptions = {
createNotFoundPage: (msg?: string) => any; createNotFoundPage: (msg?: string) => any;
oss?: OssBase; oss?: OssBase;
}; };
export const aiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => { export const aiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
const oss = new OssBase({ bucketName, client: minioClient });
if (!opts.oss) { if (!opts.oss) {
opts.oss = oss; opts.oss = oss;
} }
const searchParams = new URL(req.url || '', 'http://localhost').searchParams;
if (req.method === 'POST') { if (req.method === 'POST') {
const chunk = searchParams.get('chunk');
const chunked = searchParams.get('chunked');
if (chunk !== null || chunked !== null) {
return postChunkProxy(req, res, opts);
}
return postProxy(req, res, opts); return postProxy(req, res, opts);
} }
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
return deleteProxy(req, res, opts); return deleteProxy(req, res, opts);
} }
if (req.method === 'PUT') {
const meta = searchParams.get('meta');
if (meta) {
return updateMetadataProxy(req, res, opts);
}
return renameProxy(req, res, opts);
}
return getAiProxy(req, res, opts); return getAiProxy(req, res, opts);
}; };

View File

@@ -1,6 +1,6 @@
import { pipeline, Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { promisify } from 'node:util'; import { minioResources } from '@/modules/s3.ts';
import { bucketName, minioClient, minioResources } from '@/modules/minio.ts'; import { oss } from '@/app.ts';
import fs from 'node:fs'; import fs from 'node:fs';
import { IncomingMessage, ServerResponse } from 'node:http'; import { IncomingMessage, ServerResponse } from 'node:http';
import http from 'node:http'; import http from 'node:http';
@@ -11,14 +11,18 @@ import path from 'path';
import { getTextContentType } from '@/modules/fm-manager/index.ts'; import { getTextContentType } from '@/modules/fm-manager/index.ts';
import { logger } from '@/modules/logger.ts'; import { logger } from '@/modules/logger.ts';
import { pipeStream } from '../pipe.ts'; import { pipeStream } from '../pipe.ts';
const pipelineAsync = promisify(pipeline); import { GetObjectCommandOutput } from '@aws-sdk/client-s3';
export async function downloadFileFromMinio(fileUrl: string, destFile: string) { export async function downloadFileFromMinio(fileUrl: string, destFile: string) {
const objectName = fileUrl.replace(minioResources + '/', ''); const objectName = fileUrl.replace(minioResources + '/', '');
const objectStream = await minioClient.getObject(bucketName, objectName); const objectStream = await oss.getObject(objectName) as GetObjectCommandOutput;
const destStream = fs.createWriteStream(destFile); const body = objectStream.Body as Readable;
await pipelineAsync(objectStream, destStream);
console.log(`minio File downloaded to ${minioResources}/${objectName} \n ${destFile}`); const chunks: Uint8Array[] = [];
for await (const chunk of body) {
chunks.push(chunk);
}
fs.writeFileSync(destFile, Buffer.concat(chunks));
} }
export const filterKeys = (metaData: Record<string, string>, clearKeys: string[] = []) => { export const filterKeys = (metaData: Record<string, string>, clearKeys: string[] = []) => {
const keys = Object.keys(metaData); const keys = Object.keys(metaData);
@@ -43,8 +47,8 @@ export async function minioProxy(
const { createNotFoundPage, isDownload = false } = opts; const { createNotFoundPage, isDownload = false } = opts;
const objectName = fileUrl.replace(minioResources + '/', ''); const objectName = fileUrl.replace(minioResources + '/', '');
try { try {
const stat = await minioClient.statObject(bucketName, objectName); const stat = await oss.statObject(objectName);
if (stat.size === 0) { if (stat?.size === 0) {
createNotFoundPage('Invalid proxy url'); createNotFoundPage('Invalid proxy url');
return true; return true;
} }
@@ -54,7 +58,8 @@ export async function minioProxy(
const lastModified = stat.lastModified.toISOString(); const lastModified = stat.lastModified.toISOString();
const fileName = objectName.split('/').pop(); const fileName = objectName.split('/').pop();
const ext = path.extname(fileName || ''); const ext = path.extname(fileName || '');
const objectStream = await minioClient.getObject(bucketName, objectName); const objectStreamOutput = await oss.getObject(objectName);
const objectStream = objectStreamOutput.Body as Readable;
const headers = { const headers = {
'Content-Length': contentLength, 'Content-Length': contentLength,
etag, etag,
@@ -151,6 +156,7 @@ export const httpProxy = async (
return createNotFoundPage('Invalid proxy url:' + error.message); return createNotFoundPage('Invalid proxy url:' + error.message);
} }
} else { } else {
console.log('Proxying file: headers', headers);
res.writeHead(proxyRes.statusCode, { res.writeHead(proxyRes.statusCode, {
...headers, ...headers,
}); });

View File

@@ -1,6 +1,7 @@
import http from 'node:http'; import http from 'node:http';
import { minioClient } from '@/modules/minio.ts';
import { pipeMinioStream } from '../pipe.ts'; import { pipeMinioStream } from '../pipe.ts';
import { oss } from '@/app.ts';
import { Readable } from 'node:stream';
type ProxyInfo = { type ProxyInfo = {
path?: string; path?: string;
target: string; target: string;
@@ -15,9 +16,8 @@ export const minioProxyOrigin = async (req: http.IncomingMessage, res: http.Serv
if (objectName.startsWith(bucketName)) { if (objectName.startsWith(bucketName)) {
objectName = objectName.slice(bucketName.length); objectName = objectName.slice(bucketName.length);
} }
const objectStream = await minioClient.getObject(bucketName, objectName); const objectStream = await oss.getObject(objectName);
// objectStream.pipe(res); pipeMinioStream(objectStream.Body as Readable, res);
pipeMinioStream(objectStream, res);
} catch (error) { } catch (error) {
console.error('Error fetching object from MinIO:', error); console.error('Error fetching object from MinIO:', error);
res.statusCode = 500; res.statusCode = 500;

View File

@@ -27,6 +27,9 @@ export const getDNS = (req: http.IncomingMessage) => {
}; };
export const isLocalhost = (hostName: string) => { export const isLocalhost = (hostName: string) => {
if (!hostName) {
return false;
}
return hostName.includes('localhost') || hostName.includes('192.168'); return hostName.includes('localhost') || hostName.includes('192.168');
}; };

View File

@@ -0,0 +1,344 @@
type StudioOpts = { user: string, userAppKey?: string; appIds: string[] }
export const createStudioAppListHtml = (opts: StudioOpts) => {
const user = opts.user!;
const userAppKey = opts?.userAppKey;
let showUserAppKey = userAppKey;
if (showUserAppKey && showUserAppKey.startsWith(user + '--')) {
showUserAppKey = showUserAppKey.replace(user + '--', '');
}
const pathApps = opts?.appIds?.map(appId => {
const shortAppId = appId.replace(opts!.user + '--', '')
return {
appId,
shortAppId,
pathname: `/${user}/v1/${shortAppId}`
};
}) || []
// 应用列表内容
const appListContent = `
<div class="header">
<h1><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="12" x="2" y="6" rx="2"/><path d="M12 12h.01"/><path d="M17 12h.01"/><path d="M7 12h.01"/></svg> Studio 应用列表</h1>
<p class="user-info">用户: <strong>${user}</strong></p>
</div>
<div class="app-grid">
${pathApps.map((app, index) => `
<a href="${app.pathname}" class="app-card" style="animation-delay: ${index * 0.1}s">
<div class="app-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="M6 16h12"/><path d="M2 8h20"/></svg></div>
<div class="app-info">
<h3>${app.shortAppId}</h3>
<p class="app-path">${app.pathname}</p>
</div>
<div class="app-arrow">→</div>
</a>
`).join('')}
</div>
${pathApps.length === 0 ? `
<div class="empty-state">
<div class="empty-icon">📭</div>
<p>暂无应用</p>
</div>
` : ''}
`
return `
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Studio - ${user} 的应用</title>
<style>
:root {
--primary-color: #000000;
--primary-hover: #333333;
--text-color: #111111;
--text-secondary: #666666;
--bg-color: #ffffff;
--card-bg: #ffffff;
--border-color: #e0e0e0;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.04);
--shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 0;
min-height: 100vh;
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
/* Not Found Styles */
.not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: 3rem;
background-color: var(--card-bg);
border-radius: 16px;
border: 1px solid var(--border-color);
box-shadow: var(--shadow);
}
.not-found-icon {
width: 80px;
height: 80px;
margin-bottom: 1.5rem;
color: var(--text-secondary);
}
.not-found h1 {
font-size: 2.5rem;
color: #000000;
margin-bottom: 1rem;
}
.not-found p {
color: var(--text-secondary);
margin-bottom: 0.5rem;
max-width: 400px;
}
.not-found code {
background-color: #f5f5f5;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: 'Fira Code', 'Monaco', monospace;
color: #000000;
border: 1px solid var(--border-color);
}
.back-link {
display: inline-block;
margin-top: 1.5rem;
padding: 0.75rem 1.5rem;
background-color: var(--primary-color);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s;
}
.back-link:hover {
background-color: var(--primary-hover);
transform: translateY(-2px);
}
/* App List Styles */
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--text-color);
margin: 0 0 0.5rem 0;
}
.user-info {
color: var(--text-secondary);
margin: 0;
}
.user-info strong {
color: var(--primary-color);
}
.app-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.app-card {
display: flex;
align-items: center;
padding: 1.25rem 1.5rem;
background-color: var(--card-bg);
border-radius: 12px;
text-decoration: none;
color: inherit;
border: 1px solid var(--border-color);
box-shadow: var(--shadow);
transition: all 0.3s ease;
animation: slideIn 0.5s ease-out backwards;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.app-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-hover);
border-color: var(--primary-color);
}
.app-icon {
font-size: 2rem;
margin-right: 1rem;
flex-shrink: 0;
}
.app-info {
flex: 1;
min-width: 0;
}
.app-info h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
.app-path {
margin: 0.25rem 0 0 0;
font-size: 0.85rem;
color: var(--text-secondary);
font-family: 'Fira Code', 'Monaco', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-arrow {
font-size: 1.25rem;
color: var(--text-secondary);
transition: all 0.3s ease;
flex-shrink: 0;
}
.app-card:hover .app-arrow {
color: var(--primary-color);
transform: translateX(5px);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
color: var(--text-secondary);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.6;
}
.empty-state p {
font-size: 1.1rem;
margin: 0;
}
/* Footer */
.footer {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
text-align: center;
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
:root {
--primary-color: #ffffff;
--primary-hover: #cccccc;
--text-color: #ffffff;
--text-secondary: #999999;
--bg-color: #000000;
--card-bg: #1a1a1a;
--border-color: #333333;
}
.not-found code {
background-color: #333333;
}
}
/* Responsive */
@media (max-width: 600px) {
.container {
padding: 1rem;
}
.header h1 {
font-size: 1.5rem;
}
.app-card {
padding: 1rem;
}
.app-icon {
font-size: 1.5rem;
margin-right: 0.75rem;
}
.app-info h3 {
font-size: 1rem;
}
.app-path {
font-size: 0.75rem;
}
}
</style>
</head>
<body>
<div class="container">
${showUserAppKey ? `
<div class="not-found">
<svg class="not-found-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<h1>应用不存在</h1>
<p>抱歉,您访问的应用 <code>${showUserAppKey || ''}</code> 不存在。</p>
<p>请检查应用 Key 是否正确,或联系管理员。</p>
<a href="/${user}/v1/" class="back-link">← 返回应用列表</a>
</div>
` : ''}
${appListContent}
<div class="footer">
© ${new Date().getFullYear()} Studio - 应用管理
</div>
</div>
</body>
</html>
`;
};

View File

@@ -1,38 +1,13 @@
import { Client, ClientOptions } from 'minio'; import { Client, } from 'minio';
import { useConfig } from '@kevisual/use-config'; import { useConfig } from '@kevisual/use-config';
const config = useConfig(); const config = useConfig();
import { OssBase } from '@kevisual/oss/services';
const minioConfig = { const minioConfig = {
endPoint: config.MINIO_ENDPOINT || 'localhost', endPoint: config.MINIO_ENDPOINT || 'localhost',
// @ts-ignore
port: parseInt(config.MINIO_PORT || '9000'), port: parseInt(config.MINIO_PORT || '9000'),
useSSL: config.MINIO_USE_SSL === 'true', useSSL: config.MINIO_USE_SSL === 'true',
accessKey: config.MINIO_ACCESS_KEY, accessKey: config.MINIO_ACCESS_KEY,
secretKey: config.MINIO_SECRET_KEY, secretKey: config.MINIO_SECRET_KEY,
}; };
const { port, endPoint, useSSL } = minioConfig;
// console.log('minioConfig', minioConfig);
export const minioClient = new Client(minioConfig); export const minioClient = new Client(minioConfig);
export const bucketName = config.MINIO_BUCKET_NAME || 'resources';
export const minioUrl = `http${useSSL ? 's' : ''}://${endPoint}:${port || 9000}`;
export const minioResources = `${minioUrl}/resources`;
if (!minioClient) {
throw new Error('Minio client not initialized');
}
// 验证权限
(async () => {
const bucketExists = await minioClient.bucketExists(bucketName);
if (!bucketExists) {
await minioClient.makeBucket(bucketName);
}
console.log('bucketExists', bucketExists);
// const res = await minioClient.putObject(bucketName, 'root/test/0.0.1/a.txt', 'test');
// console.log('minio putObject', res);
})();
export const oss = new OssBase({
client: minioClient,
bucketName: bucketName,
prefix: '',
});

44
src/modules/s3.ts Normal file
View File

@@ -0,0 +1,44 @@
import { CreateBucketCommand, HeadObjectCommand, S3Client, } from '@aws-sdk/client-s3';
import { OssBase } from '@kevisual/oss/s3.ts';
import { useConfig } from '@kevisual/use-config';
const config = useConfig();
export const bucketName = config.S3_BUCKET_NAME || 'resources';
export const s3Client = new S3Client({
credentials: {
accessKeyId: config.S3_ACCESS_KEY_ID || '',
secretAccessKey: config.S3_SECRET_ACCESS_KEY || '',
},
region: config.S3_REGION,
endpoint: config.S3_ENDPOINT,
// minio配置
forcePathStyle: true,
});
// 判断 bucketName 是否存在,不存在则创建
(async () => {
try {
await s3Client.send(new HeadObjectCommand({ Bucket: bucketName, Key: '' }));
console.log(`Bucket ${bucketName} exists.`);
} catch (error) {
console.log(`Bucket ${bucketName} does not exist. Creating...`);
if (config.S3_ENDPOINT?.includes?.('9000')) {
// 创建 bucket
await s3Client.send(new CreateBucketCommand({ Bucket: bucketName }));
console.log(`Bucket ${bucketName} created.`);
}
}
})();
if (!s3Client) {
throw new Error('S3 client not initialized');
}
export const oss = new OssBase({
client: s3Client,
bucketName: bucketName,
prefix: '',
})
export const minioResources = `${config.S3_ENDPOINT}/${bucketName}`;

View File

@@ -7,7 +7,7 @@ import { nanoid } from 'nanoid';
import { pipeline } from 'stream'; import { pipeline } from 'stream';
import { promisify } from 'util'; import { promisify } from 'util';
import { getAppLoadStatus, setAppLoadStatus } from './get-app-status.ts'; import { getAppLoadStatus, setAppLoadStatus } from './get-app-status.ts';
import { minioResources } from '../minio.ts'; import { minioResources } from '../s3.ts';
import { downloadFileFromMinio, fetchApp, fetchDomain, fetchTest } from '@/modules/fm-manager/index.ts'; import { downloadFileFromMinio, fetchApp, fetchDomain, fetchTest } from '@/modules/fm-manager/index.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
export * from './get-app-status.ts'; export * from './get-app-status.ts';

97
src/modules/v3/index.ts Normal file
View File

@@ -0,0 +1,97 @@
import { IncomingMessage, ServerResponse } from 'http';
import { App } from '@kevisual/router';
import { logger } from '../logger.ts';
// import { getLoginUser } from '@/modules/auth.ts';
import { SSEManager } from './sse/sse-manager.ts';
import { getLoginUser } from '../auth.ts';
import { emitter, flowme_insert } from '../../realtime/flowme/index.ts';
export const sseManager = new SSEManager();
emitter.on(flowme_insert, (data) => {
console.log('flowme_insert event received:', data);
const uid = data.uid;
if (uid) {
sseManager.broadcast({ type: 'flowme_insert', data }, { userId: uid });
}
});
type ProxyOptions = {
createNotFoundPage: (msg?: string) => any;
};
export const UserV3Proxy = async (req: IncomingMessage, res: ServerResponse, opts?: ProxyOptions) => {
const { url } = req;
const _url = new URL(url || '', `http://localhost`);
const { pathname, searchParams } = _url;
let [user, app, ...rest] = pathname.split('/').slice(1);
if (!user || !app) {
opts?.createNotFoundPage?.('应用未找到');
return false;
}
const last = rest.slice(-1)[0] || '';
const method = req.method || 'GET';
console.log('UserV3Proxy request: last', last, rest);
if (method === 'GET' && last === 'event') {
const info = await getLoginUser(req);
if (!info) {
opts?.createNotFoundPage?.('没有登录');
return false;
}
console.log('建立 SSE 连接, user=', info.tokenUser.uid);
addEventStream(req, res, info);
return true;
}
res.end(`UserV3Proxy: user=${user}, app=${app}, rest=${rest.join('/')}`);
console.log('UserV3Proxy:', { user, app, });
return true;
};
type Opts = {
tokenUser: any;
token: string;
}
const addEventStream = (req: IncomingMessage, res: ServerResponse, opts?: Opts) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
console.log('Client connected for SSE', opts?.tokenUser?.username || 'unknown');
const uid = opts?.tokenUser?.id || 'guest';
console.log('SSE for userId=', opts?.tokenUser);
const connectionInfo = sseManager.createConnection({ userId: uid });
const { stream, id: connectionId } = connectionInfo;
// 设置心跳
connectionInfo.heartbeatInterval = setInterval(() => {
sseManager.sendToConnection(connectionId, { type: "heartbeat", timestamp: Date.now() })
.catch(() => {
// 心跳失败时清理连接
sseManager.closeConnection(connectionId);
});
}, 30000); // 30秒心跳
const timer = setInterval(async () => {
sseManager.broadcast({ type: "time", timestamp: Date.now() });
const hasId = sseManager.getConnection(connectionId);
if (!hasId) {
clearInterval(timer);
console.log('清理广播定时器,连接已关闭');
}
}, 1000);
res.pipe(stream as any);
const bun = (req as any).bun
const request = bun?.request as Bun.BunRequest<string>
if (request) {
if (request.signal) {
// 当客户端断开时清理连接
request.signal.addEventListener("abort", () => {
console.log(`Client ${connectionId} disconnected`);
sseManager.closeConnection(connectionId);
});
}
}
// console.log('res', req)
// res.end('123');
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

View File

@@ -0,0 +1,134 @@
import { nanoid } from "nanoid";
type ConnectionInfo = {
id: string;
writer: WritableStreamDefaultWriter;
stream: ReadableStream<any>;
connectedAt: Date;
heartbeatInterval: NodeJS.Timeout | null;
userId?: string;
};
export class SSEManager {
private connections: Map<string, ConnectionInfo> = new Map();
private userConnections: Map<string, Set<string>> = new Map(); // userId -> connectionIds
constructor() {
// 初始化逻辑
}
createConnection(info?: { userId?: string }): ConnectionInfo {
const connectionId = nanoid(16);
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
// 存储连接信息
const connectionInfo = {
id: connectionId,
writer,
stream: readable,
connectedAt: new Date(),
heartbeatInterval: null,
userId: info?.userId
};
this.connections.set(connectionId, connectionInfo);
// 添加到用户索引
if (info?.userId) {
const userSet = this.userConnections.get(info.userId) || new Set();
userSet.add(connectionId);
this.userConnections.set(info.userId, userSet);
}
return connectionInfo;
}
sendToConnection(connectionId: string, data: any) {
const connection = this.connections.get(connectionId);
if (connection) {
const message = `data: ${JSON.stringify(data)}\n\n`;
return connection.writer.write(new TextEncoder().encode(message));
}
throw new Error(`Connection ${connectionId} not found`);
}
getConnection(connectionId: string) {
return this.connections.get(connectionId);
}
broadcast(data: any, opts?: { userId?: string }) {
const message = `data: ${JSON.stringify(data)}\n\n`;
const promises = [];
// 指定 userId只发送给目标用户通过索引快速查找
if (opts?.userId) {
const userConnIds = this.userConnections.get(opts.userId);
if (userConnIds) {
for (const connId of userConnIds) {
const conn = this.connections.get(connId);
if (conn) {
promises.push(
conn.writer.write(new TextEncoder().encode(message))
.catch(() => {
this.closeConnection(connId);
})
);
}
}
}
return Promise.all(promises);
}
// 未指定 userId广播给所有人
for (const [id, connection] of this.connections) {
promises.push(
connection.writer.write(new TextEncoder().encode(message))
.catch(() => {
this.closeConnection(id);
})
);
}
return Promise.all(promises);
}
closeConnection(connectionId: string) {
const connection = this.connections.get(connectionId);
if (connection) {
// 清理心跳定时器
if (connection.heartbeatInterval) {
clearInterval(connection.heartbeatInterval);
}
// 从用户索引中移除
if (connection.userId) {
const userSet = this.userConnections.get(connection.userId);
if (userSet) {
userSet.delete(connectionId);
if (userSet.size === 0) {
this.userConnections.delete(connection.userId);
}
}
}
// 关闭写入器
connection.writer.close().catch(console.error);
// 从管理器中移除
this.connections.delete(connectionId);
console.log(`Connection ${connectionId} closed`);
return true;
}
return false;
}
closeAllConnections() {
for (const [connectionId, connection] of this.connections) {
this.closeConnection(connectionId);
}
}
getActiveConnections() {
return Array.from(this.connections.keys());
}
}

View File

@@ -20,8 +20,14 @@ export const wssFun: WebSocketListenerFun = async (req, res) => {
return; return;
} }
const user = loginUser?.tokenUser?.username; const user = loginUser?.tokenUser?.username;
const userApp = user + '-' + id; const userApp = user + '--' + id;
logger.debug('注册 ws 连接', userApp); logger.debug('注册 ws 连接', userApp);
const wsMessage = wsProxyManager.get(userApp);
if (wsMessage) {
logger.debug('ws 连接已存在,关闭旧连接', userApp);
wsMessage.ws.close();
wsProxyManager.unregister(userApp);
}
// @ts-ignore // @ts-ignore
wsProxyManager.register(userApp, { user, ws }); wsProxyManager.register(userApp, { user, ws });
ws.send( ws.send(

View File

@@ -2,21 +2,51 @@ import { nanoid } from 'nanoid';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import { set } from 'zod';
class WsMessage { class WsMessage {
ws: WebSocket; ws: WebSocket;
user?: string; user?: string;
emitter: EventEmitter;; emitter: EventEmitter;
private pingTimer?: NodeJS.Timeout;
private readonly PING_INTERVAL = 30000; // 30 秒发送一次 ping
constructor({ ws, user }: WssMessageOptions) { constructor({ ws, user }: WssMessageOptions) {
this.ws = ws; this.ws = ws;
this.user = user; this.user = user;
this.emitter = new EventEmitter(); this.emitter = new EventEmitter();
this.startPing();
} }
private startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.ping();
} else {
this.stopPing();
}
}, this.PING_INTERVAL);
}
private stopPing() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = undefined;
}
}
destroy() {
this.stopPing();
this.emitter.removeAllListeners();
}
async sendResponse(data: any) { async sendResponse(data: any) {
if (data.id) { if (data.id) {
this.emitter.emit(data.id, data?.data); this.emitter.emit(data.id, data?.data);
} }
} }
async sendData(data: any, opts?: { timeout?: number }) { async sendData(data: any, context?: any, opts?: { timeout?: number }) {
if (this.ws.readyState !== WebSocket.OPEN) { if (this.ws.readyState !== WebSocket.OPEN) {
return { code: 500, message: 'WebSocket is not open' }; return { code: 500, message: 'WebSocket is not open' };
} }
@@ -25,7 +55,10 @@ class WsMessage {
const message = JSON.stringify({ const message = JSON.stringify({
id, id,
type: 'proxy', type: 'proxy',
data, data: {
message: data,
context: context || {},
},
}); });
logger.info('ws-proxy sendData', message); logger.info('ws-proxy sendData', message);
this.ws.send(message); this.ws.send(message);
@@ -50,15 +83,22 @@ type WssMessageOptions = {
}; };
export class WsProxyManager { export class WsProxyManager {
wssMap: Map<string, WsMessage> = new Map(); wssMap: Map<string, WsMessage> = new Map();
constructor() { } PING_INTERVAL = 30000; // 30 秒检查一次连接状态
constructor(opts?: { pingInterval?: number }) {
if (opts?.pingInterval) {
this.PING_INTERVAL = opts.pingInterval;
}
this.checkConnceted();
}
register(id: string, opts?: { ws: WebSocket; user: string }) { register(id: string, opts?: { ws: WebSocket; user: string }) {
if (this.wssMap.has(id)) { if (this.wssMap.has(id)) {
const value = this.wssMap.get(id); const value = this.wssMap.get(id);
if (value) { if (value) {
value.ws.close(); value.ws.close();
value.destroy();
} }
} }
const [username, appId] = id.split('-'); const [username, appId] = id.split('--');
const url = new URL(`/${username}/v1/${appId}`, 'https://kevisual.cn/'); const url = new URL(`/${username}/v1/${appId}`, 'https://kevisual.cn/');
console.log('WsProxyManager register', id, '访问地址', url.toString()); console.log('WsProxyManager register', id, '访问地址', url.toString());
const value = new WsMessage({ ws: opts?.ws, user: opts?.user }); const value = new WsMessage({ ws: opts?.ws, user: opts?.user });
@@ -68,13 +108,29 @@ export class WsProxyManager {
const value = this.wssMap.get(id); const value = this.wssMap.get(id);
if (value) { if (value) {
value.ws.close(); value.ws.close();
value.destroy();
} }
this.wssMap.delete(id); this.wssMap.delete(id);
} }
getIds() { getIds(beginWith?: string) {
if (beginWith) {
return Array.from(this.wssMap.keys()).filter(key => key.startsWith(beginWith));
}
return Array.from(this.wssMap.keys()); return Array.from(this.wssMap.keys());
} }
get(id: string) { get(id: string) {
return this.wssMap.get(id); return this.wssMap.get(id);
} }
checkConnceted() {
const that = this;
setTimeout(() => {
that.wssMap.forEach((value, key) => {
if (value.ws.readyState !== WebSocket.OPEN) {
logger.debug('ws not connected, unregister', key);
that.unregister(key);
}
});
that.checkConnceted();
}, this.PING_INTERVAL);
}
} }

View File

@@ -4,6 +4,8 @@ import { wsProxyManager } from './index.ts';
import { App } from '@kevisual/router'; import { App } from '@kevisual/router';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { getLoginUser } from '@/modules/auth.ts'; import { getLoginUser } from '@/modules/auth.ts';
import { createStudioAppListHtml } from '../html/studio-app-list/index.ts';
import { omit } from 'es-toolkit';
type ProxyOptions = { type ProxyOptions = {
createNotFoundPage: (msg?: string) => any; createNotFoundPage: (msg?: string) => any;
@@ -13,13 +15,10 @@ export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opt
const _url = new URL(url || '', `http://localhost`); const _url = new URL(url || '', `http://localhost`);
const { pathname, searchParams } = _url; const { pathname, searchParams } = _url;
let [user, app, userAppKey] = pathname.split('/').slice(1); let [user, app, userAppKey] = pathname.split('/').slice(1);
if (!user || !app || !userAppKey) { if (!user || !app) {
opts?.createNotFoundPage?.('应用未找到'); opts?.createNotFoundPage?.('应用未找到');
return false; return false;
} }
if (!userAppKey.includes('-')) {
userAppKey = user + '-' + userAppKey;
}
const data = await App.handleRequest(req, res); const data = await App.handleRequest(req, res);
const loginUser = await getLoginUser(req); const loginUser = await getLoginUser(req);
@@ -27,21 +26,42 @@ export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opt
opts?.createNotFoundPage?.('没有登录'); opts?.createNotFoundPage?.('没有登录');
return false; return false;
} }
const isAdmin = loginUser.tokenUser?.username === user const isAdmin = loginUser.tokenUser?.username === user
if (!userAppKey) {
if (isAdmin) {
// 获取所有的管理员的应用列表
const ids = wsProxyManager.getIds(user + '--');
const html = createStudioAppListHtml({ user, appIds: ids, userAppKey });
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
return;
} else {
opts?.createNotFoundPage?.('没有访问应用权限');
return false;
}
}
if (!userAppKey.includes('--')) {
userAppKey = user + '--' + userAppKey;
}
// TODO: 如果不是管理员,是否需要添加其他人可以访问的逻辑? // TODO: 如果不是管理员,是否需要添加其他人可以访问的逻辑?
if (!isAdmin) { if (!isAdmin) {
opts?.createNotFoundPage?.('没有访问应用权限'); opts?.createNotFoundPage?.('没有访问应用权限');
return false; return false;
} }
if (!userAppKey.startsWith(user + '-')) { if (!userAppKey.startsWith(user + '--')) {
userAppKey = user + '-' + userAppKey; userAppKey = user + '--' + userAppKey;
} }
logger.debug('data', data); logger.debug('data', data);
const client = wsProxyManager.get(userAppKey); const client = wsProxyManager.get(userAppKey);
const ids = wsProxyManager.getIds(); const ids = wsProxyManager.getIds(user + '--');
if (!client) { if (!client) {
if (isAdmin) { if (isAdmin) {
opts?.createNotFoundPage?.(`未找到应用 [${userAppKey}], 当前应用列表: ${ids.join(',')}`); const html = createStudioAppListHtml({ user, appIds: ids, userAppKey });
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
} else { } else {
opts?.createNotFoundPage?.('应用访问失败'); opts?.createNotFoundPage?.('应用访问失败');
} }
@@ -55,7 +75,13 @@ export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opt
res.end(await html); res.end(await html);
return true; return true;
} }
const value = await client.sendData(data); let message: any = data;
if (!isAdmin) {
message = omit(data, ['token', 'cookies']);
}
const value = await client.sendData(message, {
state: { tokenUser: omit(loginUser.tokenUser, ['oauthExpand']) },
});
if (value) { if (value) {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(value)); res.end(JSON.stringify(value));

View File

@@ -0,0 +1 @@
export const flowme_insert = 'flowme_insert'

View File

@@ -0,0 +1,26 @@
import { db } from '@/modules/db.ts'
// 创建触发器函数和触发器,用于在 flowme 表插入新记录时发送通知
const sql = `CREATE OR REPLACE FUNCTION notify_flowme_insert()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('flowme_insert', row_to_json(NEW)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER flowme_after_insert
AFTER INSERT ON flowme
FOR EACH ROW
EXECUTE FUNCTION notify_flowme_insert();
`;
if (import.meta.main) {
const result = await db.execute(sql)
console.log('✅ flowme 插入触发器已创建或更新:', result)
}
// const listFunSql = `SELECT proname FROM pg_proc WHERE proname = 'flowme_after_insert';`
// const funExists = await db.execute(listFunSql)
// console.log('函数是否存在:', funExists)

View File

@@ -0,0 +1,3 @@
export * from './listener.ts'
export * from './common.ts'

View File

@@ -0,0 +1,33 @@
import { Client } from 'pg'
import { useConfig } from '@kevisual/use-config'
import { EventEmitter } from 'eventemitter3'
const config = useConfig()
let pgClient: Client | null = null
export const emitter = new EventEmitter()
async function startFlowmeListener() {
// 使用独立的数据库连接来监听
pgClient = new Client({
connectionString: config.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/code_center',
})
console.log('config.DATABASE_URL =', config.DATABASE_URL)
await pgClient.connect()
console.log('🔌 已连接到 PostgreSQL 监听器')
// 订阅通知事件
pgClient.on('notification', (data) => {
if (!data.payload) return
try {
const parsed = JSON.parse(data.payload)
emitter.emit('flowme_insert', parsed)
} catch (err) {
console.error('❌ 解析 flowme 通知失败:', err)
}
})
// 执行 LISTEN 命令订阅通道
await pgClient.query('LISTEN flowme_insert')
console.log('👂 开始监听 flowme_insert 通道...')
}
startFlowmeListener();

View File

@@ -29,12 +29,11 @@ export const addAuth = (app: App) => {
ctx.throw(401, 'Token is required'); ctx.throw(401, 'Token is required');
} }
const user = await User.getOauthUser(token); const user = await User.getOauthUser(token);
console.log('auth user: exists', !user);
if (!user) { if (!user) {
ctx.throw(401, 'Token is invalid'); ctx.throw(401, 'Token is invalid');
return; return;
} }
console.log(`auth user: ${user.username} (${user.id})`); // console.log(`auth user: ${user.username} (${user.id})`);
const someInfo = getSomeInfoFromReq(ctx); const someInfo = getSomeInfoFromReq(ctx);
if (someInfo.isBrowser && !ctx.req?.cookies?.['token']) { if (someInfo.isBrowser && !ctx.req?.cookies?.['token']) {
createCookie({ accessToken: token }, ctx); createCookie({ accessToken: token }, ctx);
@@ -87,6 +86,7 @@ app
if (!tokenUser) { if (!tokenUser) {
ctx.throw(401, 'No User For authorized'); ctx.throw(401, 'No User For authorized');
} }
console.log('auth-admin tokenUser', ctx.state);
if (typeof ctx.state.isAdmin !== 'undefined' && ctx.state.isAdmin === true) { if (typeof ctx.state.isAdmin !== 'undefined' && ctx.state.isAdmin === true) {
return; return;
} }
@@ -114,6 +114,7 @@ app
} }
}) })
.addTo(app); .addTo(app);
app app
.route({ .route({
path: 'auth-check', path: 'auth-check',

View File

@@ -1,172 +0,0 @@
import Busboy from 'busboy';
import { checkAuth } from '../middleware/auth.ts';
import { router, clients, writeEvents } from '../router.ts';
import { error } from '../middleware/auth.ts';
import fs from 'fs';
import { useFileStore } from '@kevisual/use-config';
import { app, minioClient } from '@/app.ts';
import { bucketName } from '@/modules/minio.ts';
import { getContentType } from '@/utils/get-content-type.ts';
import path from 'path';
import { createWriteStream } from 'fs';
import crypto from 'crypto';
import { pipeBusboy } from '@/modules/fm-manager/index.ts';
const cacheFilePath = useFileStore('cache-file', { needExists: true });
router.post('/api/micro-app/upload', async (req, res) => {
if (res.headersSent) return; // 如果响应已发送,不再处理
res.writeHead(200, { 'Content-Type': 'application/json' });
const { tokenUser, token } = await checkAuth(req, res);
if (!tokenUser) return;
// 使用 busboy 解析 multipart/form-data
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
const fields: any = {};
let file: any = null;
let filePromise: Promise<void> | null = null;
let bytesReceived = 0;
let bytesExpected = parseInt(req.headers['content-length'] || '0');
busboy.on('field', (fieldname, value) => {
fields[fieldname] = value;
});
busboy.on('file', (fieldname, fileStream, info) => {
const { filename, encoding, mimeType } = info;
// 处理 UTF-8 文件名编码
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
const writeStream = createWriteStream(tempPath);
const hash = crypto.createHash('md5');
let size = 0;
filePromise = new Promise<void>((resolve, reject) => {
fileStream.on('data', (chunk) => {
bytesReceived += chunk.length;
size += chunk.length;
hash.update(chunk);
if (bytesExpected > 0) {
const progress = (bytesReceived / bytesExpected) * 100;
console.log(`Upload progress: ${progress.toFixed(2)}%`);
const data = {
progress: progress.toFixed(2),
message: `Upload progress: ${progress.toFixed(2)}%`,
};
writeEvents(req, data);
}
});
fileStream.pipe(writeStream);
writeStream.on('finish', () => {
file = {
filepath: tempPath,
originalFilename: decodedFilename,
mimetype: mimeType,
hash: hash.digest('hex'),
size: size,
};
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 (file?.filepath && fs.existsSync(file.filepath)) {
fs.unlinkSync(file.filepath);
}
};
if (!file) {
res.end(error('No file uploaded'));
return;
}
let appKey, collection;
const { appKey: _appKey, collection: _collecion } = fields;
if (Array.isArray(_appKey)) {
appKey = _appKey?.[0];
} else {
appKey = _appKey;
}
if (Array.isArray(_collecion)) {
collection = _collecion?.[0];
} else {
collection = _collecion;
}
collection = parseIfJson(collection);
appKey = appKey || 'micro-app';
console.log('Appkey', appKey);
console.log('collection', collection);
// 处理上传的文件
const uploadResults = [];
const tempPath = file.filepath; // 文件上传时的临时路径
const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构)
// 比如 child2/b.txt
const minioPath = `private/${tokenUser.username}/${appKey}/${relativePath}`;
// 上传到 MinIO 并保留文件夹结构
const isHTML = relativePath.endsWith('.html');
await minioClient.fPutObject(bucketName, minioPath, tempPath, {
'Content-Type': getContentType(relativePath),
'app-source': 'user-micro-app',
'Cache-Control': isHTML ? 'no-cache' : 'max-age=31536000, immutable', // 缓存一年
});
uploadResults.push({
name: relativePath,
path: minioPath,
hash: file.hash,
size: file.size,
});
fs.unlinkSync(tempPath); // 删除临时文件
// 受控
const r = await app.call({
path: 'micro-app',
key: 'upload',
payload: {
token: token,
data: {
appKey,
collection,
files: uploadResults,
},
},
});
const data: any = {
code: r.code,
data: r.body,
};
if (r.message) {
data.message = r.message;
}
res.end(JSON.stringify(data));
});
pipeBusboy(req, res, busboy);
});
function parseIfJson(collection: any): any {
try {
return JSON.parse(collection);
} catch (e) {
return collection;
}
}

View File

@@ -1,49 +0,0 @@
import { router, error, checkAuth, clients, getTaskId, writeEvents, deleteOldClients } from './router.ts';
router.get('/api/events', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const taskId = getTaskId(req);
if (!taskId) {
res.end(error('task-id is required'));
return;
}
// 将客户端连接推送到 clients 数组
clients.set(taskId, { client: res, createTime: Date.now() });
// 移除客户端连接
req.on('close', () => {
clients.delete(taskId);
});
});
router.get('/api/s1/events', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const taskId = getTaskId(req);
if (!taskId) {
res.end(error('task-id is required'));
return;
}
// 将客户端连接推送到 clients 数组
clients.set(taskId, { client: res, createTime: Date.now() });
writeEvents(req, { progress: 0, message: 'start' });
// 不自动关闭连接
// res.end('ok');
});
router.get('/api/s1/events/close', async (req, res) => {
const taskId = getTaskId(req);
if (!taskId) {
res.end(error('task-id is required'));
return;
}
deleteOldClients();
clients.delete(taskId);
res.end('ok');
});

View File

@@ -1,214 +1,10 @@
import { useFileStore } from '@kevisual/use-config';
import http from 'node:http'; import http from 'node:http';
import fs from 'fs'; import { router } from './router.ts';
import Busboy from 'busboy';
import { app, minioClient } from '@/app.ts';
import { bucketName } from '@/modules/minio.ts';
import { getContentType } from '@/utils/get-content-type.ts';
import { User } from '@/models/user.ts';
import { router, error, checkAuth, writeEvents } from './router.ts';
import './index.ts'; import './index.ts';
import { handleRequest as PageProxy } from './page-proxy.ts'; import { handleRequest as PageProxy } from './page-proxy.ts';
import path from 'path';
import { createWriteStream } from 'fs';
import { pipeBusboy } from '@/modules/fm-manager/pipe-busboy.ts';
const cacheFilePath = useFileStore('cache-file', { needExists: true });
router.get('/api/app/upload', async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Upload API is ready');
});
router.post('/api/app/upload', async (req, res) => {
if (res.headersSent) return; // 如果响应已发送,不再处理
res.writeHead(200, { 'Content-Type': 'application/json' });
const { tokenUser, token } = await checkAuth(req, res);
if (!tokenUser) return;
// 使用 busboy 解析 multipart/form-data
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
const fields: any = {};
const files: any = [];
const filePromises: Promise<void>[] = [];
let bytesReceived = 0;
let bytesExpected = parseInt(req.headers['content-length'] || '0');
busboy.on('field', (fieldname, value) => {
fields[fieldname] = value;
});
busboy.on('file', (fieldname, fileStream, info) => {
const { filename, encoding, mimeType } = info;
// 处理 UTF-8 文件名编码
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
const writeStream = createWriteStream(tempPath);
const filePromise = new Promise<void>((resolve, reject) => {
fileStream.on('data', (chunk) => {
bytesReceived += chunk.length;
if (bytesExpected > 0) {
const progress = (bytesReceived / bytesExpected) * 100;
console.log(`Upload progress: ${progress.toFixed(2)}%`);
const data = {
progress: progress.toFixed(2),
message: `Upload progress: ${progress.toFixed(2)}%`,
};
writeEvents(req, data);
}
});
fileStream.pipe(writeStream);
writeStream.on('finish', () => {
files.push({
filepath: tempPath,
originalFilename: decodedFilename,
mimetype: mimeType,
});
resolve();
});
writeStream.on('error', (err) => {
reject(err);
});
});
filePromises.push(filePromise);
});
busboy.on('finish', async () => {
// 等待所有文件写入完成
try {
await Promise.all(filePromises);
} catch (err) {
console.error(`File write error: ${err.message}`);
res.end(error(`File write error: ${err.message}`));
return;
}
const clearFiles = () => {
files.forEach((file: any) => {
if (file?.filepath && fs.existsSync(file.filepath)) {
fs.unlinkSync(file.filepath);
}
});
};
// 检查是否有文件上传
if (files.length === 0) {
res.end(error('files is required'));
return;
}
let appKey,
version,
username = '';
const { appKey: _appKey, version: _version, username: _username } = fields;
if (Array.isArray(_appKey)) {
appKey = _appKey?.[0];
} else {
appKey = _appKey;
}
if (Array.isArray(_version)) {
version = _version?.[0];
} else {
version = _version;
}
if (Array.isArray(_username)) {
username = _username?.[0];
} else if (_username) {
username = _username;
}
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;
}
}
if (!appKey) {
res.end(error('appKey is required'));
clearFiles();
return;
}
if (!version) {
res.end(error('version is required'));
clearFiles();
return;
}
console.log('Appkey', appKey, version);
// 逐个处理每个上传的文件
const uploadResults = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const tempPath = file.filepath; // 文件上传时的临时路径
const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构)
// 比如 child2/b.txt
const minioPath = `${username || tokenUser.username}/${appKey}/${version}/${relativePath}`;
// 上传到 MinIO 并保留文件夹结构
const isHTML = relativePath.endsWith('.html');
await minioClient.fPutObject(bucketName, minioPath, tempPath, {
'Content-Type': getContentType(relativePath),
'app-source': 'user-app',
'Cache-Control': isHTML ? 'no-cache' : 'max-age=31536000, immutable', // 缓存一年
});
uploadResults.push({
name: relativePath,
path: minioPath,
});
fs.unlinkSync(tempPath); // 删除临时文件
}
// 受控
const r = await app.call({
path: 'app',
key: 'uploadFiles',
payload: {
token: token,
data: {
appKey,
version,
username,
files: uploadResults,
},
},
});
const data: any = {
code: r.code,
data: r.body,
};
if (r.message) {
data.message = r.message;
}
res.end(JSON.stringify(data));
});
pipeBusboy(req, res, busboy);
});
router.all('/api/nocodb-test/router', async (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
const param = await router.getSearch(req);
const body = await router.getBody(req);
const contentType = req.headers['content-type'] || '';
console.log('Content-Type:', contentType);
console.log('NocoDB test router called.', req.method, param, JSON.stringify(body, null));
res.end(JSON.stringify({ message: 'NocoDB test router is working' }));
});
const simpleAppsPrefixs = [ const simpleAppsPrefixs = [
"/api/app/", "/api/wxmsg"
"/api/micro-app/",
"/api/events",
"/api/s1/",
"/api/container/",
"/api/resource/",
"/api/wxmsg",
"/api/nocodb-test/"
]; ];

View File

@@ -1,6 +0,0 @@
// import './code/upload.ts';
import './event.ts';
import './resources/upload.ts';
import './resources/chunk.ts';
// import './resources/get-resources.ts';

View File

@@ -1,61 +0,0 @@
import { User } from '@/models/user.ts';
import http from 'http';
import { parse } from '@kevisual/router/src/server/cookie.ts';
export const error = (msg: string, code = 500) => {
return JSON.stringify({ code, message: msg });
};
export const checkAuth = async (req: http.IncomingMessage, res: http.ServerResponse) => {
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
const url = new URL(req.url || '', 'http://localhost');
const resNoPermission = () => {
res.statusCode = 401;
res.end(error('Invalid authorization'));
return { tokenUser: null, token: null };
};
if (!token) {
token = url.searchParams.get('token') || '';
}
if (!token) {
const parsedCookies = parse(req.headers.cookie || '');
token = parsedCookies.token || '';
}
if (!token) {
return resNoPermission();
}
if (token) {
token = token.replace('Bearer ', '');
}
let tokenUser;
try {
tokenUser = await User.verifyToken(token);
} catch (e) {
console.log('checkAuth error', e);
res.statusCode = 401;
res.end(error('Invalid token'));
return { tokenUser: null, token: null };
}
return { tokenUser, token };
};
export const getLoginUser = async (req: http.IncomingMessage) => {
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
const url = new URL(req.url || '', 'http://localhost');
if (!token) {
token = url.searchParams.get('token') || '';
}
if (!token) {
const parsedCookies = parse(req.headers.cookie || '');
token = parsedCookies.token || '';
}
if (token) {
token = token.replace('Bearer ', '');
}
let tokenUser;
try {
tokenUser = await User.verifyToken(token);
return { tokenUser, token };
} catch (e) {
return null;
}
};

View File

@@ -1 +0,0 @@
export * from './auth.ts'

View File

@@ -1,18 +0,0 @@
import { minioClient } from '@/app.ts';
import { bucketName } from '@/modules/minio.ts';
import { router } from '../router.ts';
router.post('/api/minio', async (ctx) => {
let { username, appKey } = { username: '', appKey: '' };
const path = `${username}/${appKey}`;
const res = await minioClient.listObjectsV2(bucketName, path, true);
const file = res.filter((item) => item.isFile);
const fileList = file.map((item) => {
return {
name: item.name,
size: item.size,
};
});
// ctx.body = fileList;
});

View File

@@ -1,107 +0,0 @@
/**
* 更新时间2025-03-17
* 第二次更新2025-03-22
*/
import { minioClient } from '@/app.ts';
import { IncomingMessage, ServerResponse } from 'http';
import { bucketName } from '@/modules/minio.ts';
import { getLoginUser } from '../middleware/auth.ts';
import { BucketItemStat } from 'minio';
import { UserPermission, Permission } from '@kevisual/permission';
import { pipeMinioStream } from '@/modules/fm-manager/index.ts';
/**
* 过滤 metaData 中的 key, 去除 password, accesskey, secretkey
* 并返回过滤后的 metaData
* @param metaData
* @returns
*/
const filterKeys = (metaData: Record<string, string>, clearKeys: string[] = []) => {
const keys = Object.keys(metaData);
// remove X-Amz- meta data
const removeKeys = ['password', 'accesskey', 'secretkey', ...clearKeys];
const filteredKeys = keys.filter((key) => !removeKeys.includes(key));
return filteredKeys.reduce((acc, key) => {
acc[key] = metaData[key];
return acc;
}, {} as Record<string, string>);
};
export const NotFoundFile = (res: ServerResponse, msg?: string, code = 404) => {
res.writeHead(code, { 'Content-Type': 'text/plain' });
res.end(msg || 'Not Found File');
return;
};
export const shareType = ['public', 'private', 'protected'] as const;
export type ShareType = (typeof shareType)[number];
export const authMinio = async (req: IncomingMessage, res: ServerResponse, objectName: string) => {
let stat: BucketItemStat;
try {
stat = await minioClient.statObject(bucketName, objectName);
} catch (e) {
return NotFoundFile(res);
}
const [userKey, ...rest] = objectName.split('/');
const _url = new URL(req.url || '', 'http://localhost');
const password = _url.searchParams.get('p') || '';
const isDownload = !!_url.searchParams.get('download');
const metaData = stat.metaData || {};
const filteredMetaData = filterKeys(metaData, ['size', 'etag', 'last-modified']);
if (stat.size === 0) {
return NotFoundFile(res);
}
const { tokenUser } = await getLoginUser(req);
const username = tokenUser?.username;
const owner = userKey;
const permission = new UserPermission({
permission: metaData as Permission,
owner,
});
const checkPermissionResult = permission.checkPermissionSuccess({
username,
password,
});
if (!checkPermissionResult.success) {
return NotFoundFile(res, checkPermissionResult.message, checkPermissionResult.code);
}
const contentLength = stat.size;
const etag = stat.etag;
const lastModified = stat.lastModified.toISOString();
const filename = objectName.split('/').pop() || 'no-file-name-download'; // Extract filename from objectName
const fileExtension = filename.split('.').pop()?.toLowerCase() || '';
const viewableExtensions = [
'jpg',
'jpeg',
'png',
'gif',
'svg',
'webp',
'mp4',
'webm',
'mp3',
'wav',
'ogg',
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
];
const contentDisposition = viewableExtensions.includes(fileExtension) && !isDownload ? 'inline' : `attachment; filename="${filename}"`;
res.writeHead(200, {
'Content-Length': contentLength,
etag,
'last-modified': lastModified,
'Content-Disposition': contentDisposition,
...filteredMetaData,
});
const objectStream = await minioClient.getObject(bucketName, objectName);
// objectStream.pipe(res, { end: true });
pipeMinioStream(objectStream, res);
};

View File

@@ -13,6 +13,7 @@ import { getLoginUser } from '../modules/auth.ts';
import { rediretHome } from '../modules/user-app/index.ts'; import { rediretHome } from '../modules/user-app/index.ts';
import { logger } from '../modules/logger.ts'; import { logger } from '../modules/logger.ts';
import { UserV1Proxy } from '../modules/ws-proxy/proxy.ts'; import { UserV1Proxy } from '../modules/ws-proxy/proxy.ts';
import { UserV3Proxy } from '@/modules/v3/index.ts';
import { hasBadUser, userIsBanned, appIsBanned, userPathIsBanned } from '@/modules/off/index.ts'; import { hasBadUser, userIsBanned, appIsBanned, userPathIsBanned } from '@/modules/off/index.ts';
import { robotsTxt } from '@/modules/html/index.ts'; import { robotsTxt } from '@/modules/html/index.ts';
import { isBun } from '@/utils/get-engine.ts'; import { isBun } from '@/utils/get-engine.ts';
@@ -145,7 +146,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
let user, app; let user, app;
let domainApp = false; let domainApp = false;
const isDev = isLocalhost(dns.hostName); const isDev = isLocalhost(dns?.hostName);
if (isDev) { if (isDev) {
console.debug('开发环境访问:', req.url, 'Host:', dns.hostName); console.debug('开发环境访问:', req.url, 'Host:', dns.hostName);
} else { } else {
@@ -194,8 +195,8 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
if (!domainApp) { if (!domainApp) {
// 原始url地址 // 原始url地址
const urls = url.split('/'); const urls = url.split('/');
const [_, _user, _app] = urls;
if (urls.length < 3) { if (urls.length < 3) {
const [_, _user] = urls;
if (_user === 'robots.txt') { if (_user === 'robots.txt') {
res.writeHead(200, { 'Content-Type': 'text/plain' }); res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(robotsTxt); res.end(robotsTxt);
@@ -212,8 +213,12 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
forBadUser(req, res); forBadUser(req, res);
} }
return res.end(); return res.end();
} else {
if (userPathIsBanned(_user) || userPathIsBanned(_app)) {
logger.warn(`Bad user access from IP: ${dns.ip}, Host: ${dns.hostName}, URL: ${req.url}`);
return forBadUser(req, res);
}
} }
const [_, _user, _app] = urls;
if (_app && urls.length === 3) { if (_app && urls.length === 3) {
// 重定向到 // 重定向到
res.writeHead(302, { Location: `${url}/` }); res.writeHead(302, { Location: `${url}/` });
@@ -250,6 +255,11 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
createNotFoundPage, createNotFoundPage,
}); });
} }
if (user !== 'api' && app === 'v3') {
return UserV3Proxy(req, res, {
createNotFoundPage,
});
}
const userApp = new UserApp({ user, app }); const userApp = new UserApp({ user, app });
let isExist = await userApp.getExist(); let isExist = await userApp.getExist();
@@ -288,6 +298,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
username: loginUser?.tokenUser?.username || '', username: loginUser?.tokenUser?.username || '',
password: password, password: password,
}); });
console.log('checkPermission', checkPermission, 'loginUser:', loginUser, password)
if (!checkPermission.success) { if (!checkPermission.success) {
return createNotFoundPage('no permission'); return createNotFoundPage('no permission');
} }

View File

@@ -1,238 +0,0 @@
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, defCharset: 'utf-8' });
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;
// 处理 UTF-8 文件名编码
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
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: decodedFilename,
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);
});

View File

@@ -1,19 +0,0 @@
import { router } from '@/app.ts';
import { authMinio } from '../minio/get-minio-resource.ts';
// 功能可以抽离为某一个dns请求的服务
router.all('/api/s1/share/*splat', async (req, res) => {
try {
const url = req.url;
const _url = new URL(url || '', 'http://localhost');
let objectName = _url.pathname.replace('/api/s1/share/', '');
objectName = decodeURIComponent(objectName);
await authMinio(req, res, objectName);
} catch (e) {
console.log('get share resource error url', req.url);
console.error('get share resource is error.', e.message);
res.end('get share resource is error.');
}
});

View File

@@ -1,290 +0,0 @@
import { useFileStore } from '@kevisual/use-config';
import { checkAuth, error, router, writeEvents, getKey } from '../router.ts';
import Busboy from 'busboy';
import { app, minioClient } from '@/app.ts';
import { bucketName } from '@/modules/minio.ts';
import { getContentType } from '@/utils/get-content-type.ts';
import { User } from '@/models/user.ts';
import fs from 'fs';
import path from 'path';
import { createWriteStream } from 'fs';
import { pipeBusboy } from '@/modules/fm-manager/pipe-busboy.ts';
import { ConfigModel } from '@/routes/config/models/model.ts';
import { validateDirectory } from './util.ts';
import { pick } from 'es-toolkit';
import { getFileStat } from '@/routes/file/index.ts';
import { logger } from '@/modules/logger.ts';
const cacheFilePath = useFileStore('cache-file', { needExists: true });
router.get('/api/s1/resources/upload', async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Upload API is ready');
});
export const parseIfJson = (data = '{}') => {
try {
const _data = JSON.parse(data);
if (typeof _data === 'object') return _data;
return {};
} catch (error) {
return {};
}
};
router.post('/api/s1/resources/upload/check', async (req, res) => {
const { tokenUser, token } = await checkAuth(req, res);
if (!tokenUser) {
res.end(error('Token is invalid.'));
return;
}
console.log('data', req.url);
res.writeHead(200, { 'Content-Type': 'application/json' });
const data = await router.getBody(req);
type Data = {
appKey: string;
version: string;
username: string;
directory: string;
files: { path: string; hash: string }[];
};
let { appKey, version, username, directory, files } = pick(data, ['appKey', 'version', 'username', 'directory', 'files']) as Data;
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'));
return;
}
const _user = await User.findOne({ where: { username } });
uid = _user?.id || '';
}
if (!appKey || !version) {
res.end(error('appKey and version is required'));
}
const { code, message } = validateDirectory(directory);
if (code !== 200) {
res.end(error(message));
return;
}
type CheckResult = {
path: string;
stat: any;
resourcePath: string;
hash: string;
uploadHash: string;
isUpload?: boolean;
};
const checkResult: CheckResult[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const relativePath = file.path;
const minioPath = `${username || tokenUser.username}/${appKey}/${version}${directory ? `/${directory}` : ''}/${relativePath}`;
let stat = await getFileStat(minioPath, true);
const statHash = stat?.etag || '';
checkResult.push({
path: relativePath,
uploadHash: file.hash,
resourcePath: minioPath,
isUpload: statHash === file.hash,
stat,
hash: statHash,
});
}
res.end(JSON.stringify({ code: 200, data: checkResult }));
});
// /api/s1/resources/upload
router.post('/api/s1/resources/upload', async (req, res) => {
const { tokenUser, token } = await checkAuth(req, res);
if (!tokenUser) {
res.end(error('Token is invalid.'));
return;
}
const url = new URL(req.url || '', 'http://localhost');
const share = !!url.searchParams.get('public');
const meta = parseIfJson(url.searchParams.get('meta'));
const noCheckAppFiles = !!url.searchParams.get('noCheckAppFiles');
// 使用 busboy 解析 multipart/form-data
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
const fields: any = {};
const files: any[] = [];
const filePromises: Promise<void>[] = [];
let bytesReceived = 0;
let bytesExpected = parseInt(req.headers['content-length'] || '0');
busboy.on('field', (fieldname, value) => {
fields[fieldname] = value;
});
busboy.on('file', (fieldname, fileStream, info) => {
const { filename, encoding, mimeType } = info;
// 处理 UTF-8 文件名编码busboy 可能返回 Latin-1 编码的缓冲区)
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
const writeStream = createWriteStream(tempPath);
const filePromise = new Promise<void>((resolve, reject) => {
fileStream.on('data', (chunk) => {
bytesReceived += chunk.length;
if (bytesExpected > 0) {
const progress = (bytesReceived / bytesExpected) * 100;
const data = {
progress: progress.toFixed(2),
message: `Upload progress: ${progress.toFixed(2)}%`,
};
console.log('progress-upload', data);
writeEvents(req, data);
}
});
fileStream.pipe(writeStream);
writeStream.on('finish', () => {
files.push({
filepath: tempPath,
originalFilename: decodedFilename,
mimetype: mimeType,
});
resolve();
});
writeStream.on('error', (err) => {
reject(err);
});
});
filePromises.push(filePromise);
});
busboy.on('finish', async () => {
// 等待所有文件写入完成
try {
await Promise.all(filePromises);
} catch (err) {
logger.error(`File write error: ${err.message}`);
res.end(error(`File write error: ${err.message}`));
return;
}
const clearFiles = () => {
files.forEach((file) => {
if (file?.filepath && fs.existsSync(file.filepath)) {
fs.unlinkSync(file.filepath);
}
});
};
// 检查是否有文件上传
if (files.length === 0) {
res.end(error('files is required'));
return;
}
let { appKey, version, username, directory, description } = getKey(fields, ['appKey', 'version', 'username', 'directory', 'description']);
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 uploadedFiles = files;
logger.info(
'upload files',
uploadedFiles.map((item) => {
return pick(item, ['filepath', 'originalFilename']);
}),
);
const uploadResults = [];
for (let i = 0; i < uploadedFiles.length; i++) {
const file = uploadedFiles[i];
// @ts-ignore
const tempPath = file.filepath; // 文件上传时的临时路径
const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构)
// 比如 child2/b.txt
const minioPath = `${username || tokenUser.username}/${appKey}/${version}${directory ? `/${directory}` : ''}/${relativePath}`;
// 上传到 MinIO 并保留文件夹结构
const isHTML = relativePath.endsWith('.html');
const metadata: any = {};
if (share) {
metadata.share = 'public';
}
Object.assign(metadata, meta);
await minioClient.fPutObject(bucketName, minioPath, tempPath, {
'Content-Type': getContentType(relativePath),
'app-source': 'user-app',
'Cache-Control': isHTML ? 'no-cache' : 'max-age=31536000, immutable', // 缓存一年
...metadata,
});
uploadResults.push({
name: relativePath,
path: minioPath,
});
fs.unlinkSync(tempPath); // 删除临时文件
}
if (!noCheckAppFiles) {
const _data = { appKey, version, username, files: uploadResults, description, }
if (_data.description) {
delete _data.description;
}
// 受控
const r = await app.call({
path: 'app',
key: 'uploadFiles',
payload: {
token: token,
data: _data,
},
});
const data: any = {
code: r.code,
data: {
app: r.body,
upload: uploadResults,
},
};
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,
data: {
detect: [],
upload: uploadResults,
},
}),
);
}
});
pipeBusboy(req, res, busboy);
});

View File

@@ -1,30 +0,0 @@
/**
* 校验directory是否合法, 合法返回200, 不合法返回500
*
* directory 不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
* 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
* @param directory 目录
* @returns
*/
export const validateDirectory = (directory?: string) => {
// 对directory进行校验不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
if (directory && (directory.startsWith('/') || directory.endsWith('/') || directory.startsWith('..') || directory.endsWith('..'))) {
return {
code: 500,
message: 'directory is invalid',
};
}
// 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
// 可以包含.
let _directory = directory?.replace(/\//g, '');
if (_directory && !/^[a-zA-Z0-9_.-]+$/.test(_directory)) {
return {
code: 500,
message: 'directory is invalid, only number, letter, underline and hyphen are allowed',
};
}
return {
code: 200,
message: 'directory is valid',
};
};

View File

@@ -1,8 +1,7 @@
import { router } from '@/app.ts'; import { router } from '@/app.ts';
import http from 'http'; import http from 'http';
import { useContextKey } from '@kevisual/context'; import { useContextKey } from '@kevisual/context';
import { checkAuth, error } from './middleware/auth.ts'; export { router, };
export { router, checkAuth, error };
/** /**
* 事件客户端 * 事件客户端

View File

@@ -6,6 +6,7 @@ import { getUidByUsername, prefixFix } from './util.ts';
import { deleteFiles, getMinioListAndSetToAppList } from '../file/index.ts'; import { deleteFiles, getMinioListAndSetToAppList } from '../file/index.ts';
import { setExpire } from './revoke.ts'; import { setExpire } from './revoke.ts';
import { User } from '@/models/user.ts'; import { User } from '@/models/user.ts';
import { callDetectAppVersion } from './export.ts';
app app
.route({ .route({
path: 'app', path: 'app',
@@ -43,7 +44,7 @@ app
console.log('get app manager called'); console.log('get app manager called');
const tokenUser = ctx.state.tokenUser; const tokenUser = ctx.state.tokenUser;
const id = ctx.query.id; const id = ctx.query.id;
const { key, version } = ctx.query?.data || {}; const { key, version, create = false } = ctx.query?.data || {};
if (!id && (!key || !version)) { if (!id && (!key || !version)) {
throw new CustomError('id is required'); throw new CustomError('id is required');
} }
@@ -59,8 +60,27 @@ app
}, },
}); });
} }
if (!am && create) {
am = await AppListModel.create({
key,
version,
uid: tokenUser.id,
data: {},
});
const res = await app.run({ path: 'app', key: "detectVersionList", payload: { data: { appKey: key, version, username: tokenUser.username }, token: ctx.query.token } });
if (res.code !== 200) {
ctx.throw(res.message || 'detect version list error');
}
am = await AppListModel.findOne({
where: {
key,
version,
uid: tokenUser.id,
},
});
}
if (!am) { if (!am) {
throw new CustomError('app not found'); ctx.throw('app not found');
} }
console.log('get app', am.id, am.key, am.version); console.log('get app', am.id, am.key, am.version);
ctx.body = prefixFix(am, tokenUser.username); ctx.body = prefixFix(am, tokenUser.username);
@@ -239,7 +259,7 @@ app
}) })
.define(async (ctx) => { .define(async (ctx) => {
const tokenUser = ctx.state.tokenUser; const tokenUser = ctx.state.tokenUser;
const { id, username, appKey, version } = ctx.query.data; const { id, username, appKey, version, detect } = ctx.query.data;
if (!id && !appKey) { if (!id && !appKey) {
throw new CustomError('id or appKey is required'); throw new CustomError('id or appKey is required');
} }
@@ -249,22 +269,33 @@ app
if (id) { if (id) {
appList = await AppListModel.findByPk(id); appList = await AppListModel.findByPk(id);
if (appList?.uid !== uid) { if (appList?.uid !== uid) {
throw new CustomError('no permission'); ctx.throw('no permission');
} }
} }
if (!appList && appKey) { if (!appList && appKey) {
if (!version) { if (!version) {
throw new CustomError('version is required'); ctx.throw('version is required');
} }
appList = await AppListModel.findOne({ where: { key: appKey, version, uid } }); appList = await AppListModel.findOne({ where: { key: appKey, version, uid } });
} }
if (!appList) { if (!appList) {
throw new CustomError('app not found'); ctx.throw('app 未发现');
} }
if (detect) {
const appKey = appList.key;
const version = appList.version;
// 自动检测最新版本
const res = await callDetectAppVersion({ appKey, version, username: username || tokenUser.username }, ctx.query.token);
if (res.code !== 200) {
ctx.throw(res.message || '检测版本列表失败');
}
appList = await AppListModel.findByPk(appList.id);
}
const files = appList.data.files || []; const files = appList.data.files || [];
const am = await AppModel.findOne({ where: { key: appList.key, uid: uid } }); const am = await AppModel.findOne({ where: { key: appList.key, uid: uid } });
if (!am) { if (!am) {
throw new CustomError('app not found'); ctx.throw('app 未发现');
} }
await am.update({ data: { ...am.data, files }, version: appList.version }); await am.update({ data: { ...am.data, files }, version: appList.version });
setExpire(appList.key, am.user); setExpire(appList.key, am.user);
@@ -366,7 +397,7 @@ app
am = await AppModel.create({ am = await AppModel.create({
title: appKey, title: appKey,
key: appKey, key: appKey,
version: version || '0.0.0', version: version || '0.0.1',
user: checkUsername, user: checkUsername,
uid, uid,
data: { files: needAddFiles }, data: { files: needAddFiles },

View File

@@ -3,7 +3,6 @@ import { app, redis } from '@/app.ts';
import fs from 'fs'; import fs from 'fs';
import { fileStore } from '@/modules/config.ts'; import { fileStore } from '@/modules/config.ts';
import { getAppLoadStatus } from '@/modules/user-app/index.ts'; import { getAppLoadStatus } from '@/modules/user-app/index.ts';
import { getLoginUser } from '@/modules/auth.ts';
export class CenterUserApp { export class CenterUserApp {
user: string; user: string;
@@ -55,25 +54,25 @@ export class CenterUserApp {
deleteUserAppFiles(user, app); deleteUserAppFiles(user, app);
} }
} }
app // app
.route({ // .route({
path: 'page-proxy-app', // path: 'page-proxy-app',
key: 'auth-admin', // key: 'auth-admin',
id: 'auth-admin', // id: 'auth-admin',
}) // })
.define(async (ctx) => { // .define(async (ctx) => {
const { user } = ctx.query; // const { user } = ctx.query;
const loginUser = await getLoginUser(ctx.req); // const loginUser = await getLoginUser(ctx.req);
if (loginUser) { // if (loginUser) {
const root = ['admin', 'root']; // const root = ['admin', 'root'];
if (root.includes(loginUser.tokenUser?.username)) { // if (root.includes(loginUser.tokenUser?.username)) {
return; // return;
} // }
ctx.throw(401, 'No Proxy App Permission'); // ctx.throw(401, 'No Proxy App Permission');
} // }
ctx.throw(401, 'No Login And No Proxy App Permission'); // ctx.throw(401, 'No Login And No Proxy App Permission');
}) // })
.addTo(app); // .addTo(app);
app app
.route({ .route({
@@ -81,7 +80,6 @@ app
key: 'list', key: 'list',
middleware: ['auth-admin'], middleware: ['auth-admin'],
description: '获取应用列表', description: '获取应用列表',
isDebug: true,
}) })
.define(async (ctx) => { .define(async (ctx) => {
const keys = await redis.keys('user:app:*'); const keys = await redis.keys('user:app:*');
@@ -101,6 +99,7 @@ app
path: 'page-proxy-app', path: 'page-proxy-app',
key: 'delete', key: 'delete',
middleware: ['auth-admin'], middleware: ['auth-admin'],
description: '删除应用缓存',
}) })
.define(async (ctx) => { .define(async (ctx) => {
const { user, app } = ctx.query; const { user, app } = ctx.query;
@@ -119,6 +118,8 @@ app
.route({ .route({
path: 'page-proxy-app', path: 'page-proxy-app',
key: 'deleteAll', key: 'deleteAll',
middleware: ['auth-admin'],
description: '删除所有应用缓存',
}) })
.define(async (ctx) => { .define(async (ctx) => {
const keys = await redis.keys('user:app:*'); const keys = await redis.keys('user:app:*');
@@ -134,7 +135,9 @@ app
app app
.route({ .route({
path: 'page-proxy-app', path: 'page-proxy-app',
description: '清理所有应用缓存',
key: 'clear', key: 'clear',
middleware: ['auth-admin'],
}) })
.define(async (ctx) => { .define(async (ctx) => {
const keys = await redis.keys('user:app:*'); const keys = await redis.keys('user:app:*');
@@ -153,6 +156,7 @@ app
.route({ .route({
path: 'page-proxy-app', path: 'page-proxy-app',
key: 'get', key: 'get',
description: '获取应用缓存信息',
middleware: ['auth-admin'], middleware: ['auth-admin'],
}) })
.define(async (ctx) => { .define(async (ctx) => {
@@ -178,6 +182,7 @@ app
.route({ .route({
path: 'page-proxy-app', path: 'page-proxy-app',
key: 'status', key: 'status',
description: '获取应用加载状态',
middleware: [], middleware: [],
}) })
.define(async (ctx) => { .define(async (ctx) => {

View File

@@ -1,8 +1,8 @@
import { app } from '@/app.ts'; import { eq, and, inArray } from 'drizzle-orm';
import { ConfigModel } from './models/model.ts'; import { app, db, schema } from '@/app.ts';
import { oss } from '@/app.ts'; import { oss } from '@/app.ts';
import { ConfigOssService } from '@kevisual/oss/services'; import { ConfigOssService } from '@kevisual/oss/services';
import { Op } from 'sequelize'; import { nanoid } from 'nanoid';
app app
.route({ .route({
@@ -20,14 +20,12 @@ app
}, },
}); });
const { list, keys, keyEtagMap } = await configOss.getList(); const { list, keys, keyEtagMap } = await configOss.getList();
const configList = await ConfigModel.findAll({ const configList = await db.select()
where: { .from(schema.kvConfig)
key: { .where(and(
[Op.in]: keys, inArray(schema.kvConfig.key, keys),
}, eq(schema.kvConfig.uid, tokenUser.id)
uid: tokenUser.id, ));
},
});
const needUpdateList = list.filter((item) => { const needUpdateList = list.filter((item) => {
const key = item.key; const key = item.key;
const hash = keyEtagMap.get(key); const hash = keyEtagMap.get(key);
@@ -43,30 +41,33 @@ app
const keyETag = keyEtagMap.get(key); const keyETag = keyEtagMap.get(key);
const configData = keyDataMap.get(key); const configData = keyDataMap.get(key);
if (keyETag && configData) { if (keyETag && configData) {
const [config, created] = await ConfigModel.findOrCreate({ const existing = await db.select()
where: { .from(schema.kvConfig)
key, .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tokenUser.id)))
uid: tokenUser.id, .limit(1);
},
defaults: { let config;
if (existing.length === 0) {
const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
key, key,
title: key, title: key,
description: `${key}:${keyETag} 同步而来`, description: `${key}:${keyETag} 同步而来`,
uid: tokenUser.id, uid: tokenUser.id,
hash: keyETag, hash: keyETag,
data: configData, data: configData,
}, }).returning();
}); config = inserted[0];
if (!created) { } else {
await config.update( const updated = await db.update(schema.kvConfig)
{ .set({
hash: keyETag, hash: keyETag,
data: json, data: json,
}, updatedAt: new Date().toISOString(),
{ })
fields: ['hash', 'data'], .where(eq(schema.kvConfig.id, existing[0].id))
}, .returning();
); config = updated[0];
} }
updateList.push(config); updateList.push(config);
} }

View File

@@ -1,7 +1,8 @@
import { app } from '@/app.ts'; import { eq, and } from 'drizzle-orm';
import { ConfigModel } from './models/model.ts'; import { app, db, schema } from '@/app.ts';
import { User } from '@/models/user.ts'; import { User } from '@/models/user.ts';
import { defaultKeys } from './models/default-keys.ts'; import { defaultKeys } from './models/default-keys.ts';
import { nanoid } from 'nanoid';
app app
.route({ .route({
@@ -27,19 +28,28 @@ app
} }
const defaultConfig = defaultKeys.find((item) => item.key === configKey); const defaultConfig = defaultKeys.find((item) => item.key === configKey);
const [config, created] = await ConfigModel.findOrCreate({ const existing = await db.select()
where: { .from(schema.kvConfig)
key: configKey, .where(and(
uid: tokenUser.id, eq(schema.kvConfig.key, configKey),
}, eq(schema.kvConfig.uid, tokenUser.id)
defaults: { ))
.limit(1);
let config;
if (existing.length === 0) {
const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
title: defaultConfig?.key, title: defaultConfig?.key,
description: defaultConfig?.description || '', description: defaultConfig?.description || '',
key: configKey, key: configKey,
uid: tokenUser.id, uid: tokenUser.id,
data: defaultConfig?.data, data: defaultConfig?.data,
}, }).returning();
}); config = inserted[0];
} else {
config = existing[0];
}
ctx.body = config; ctx.body = config;
}) })

View File

@@ -1,8 +1,9 @@
import { app } from '@/app.ts'; import { eq, desc, and, inArray } from 'drizzle-orm';
import { ConfigModel } from './models/model.ts'; import { app, db, schema } from '@/app.ts';
import { ShareConfigService } from './services/share.ts'; import { ShareConfigService } from './services/share.ts';
import { oss } from '@/app.ts'; import { oss } from '@/app.ts';
import { ConfigOssService } from '@kevisual/oss/services'; import { ConfigOssService } from '@kevisual/oss/services';
import { nanoid } from 'nanoid';
app app
.route({ .route({
@@ -13,12 +14,10 @@ app
}) })
.define(async (ctx) => { .define(async (ctx) => {
const { id } = ctx.state.tokenUser; const { id } = ctx.state.tokenUser;
const config = await ConfigModel.findAll({ const config = await db.select()
where: { .from(schema.kvConfig)
uid: id, .where(eq(schema.kvConfig.uid, id))
}, .orderBy(desc(schema.kvConfig.updatedAt));
order: [['updatedAt', 'DESC']],
});
ctx.body = { ctx.body = {
list: config, list: config,
}; };
@@ -36,9 +35,10 @@ app
const tokernUser = ctx.state.tokenUser; const tokernUser = ctx.state.tokenUser;
const tuid = tokernUser.id; const tuid = tokernUser.id;
const { id, data, ...rest } = ctx.query?.data || {}; const { id, data, ...rest } = ctx.query?.data || {};
let config: ConfigModel; let config: any;
if (id) { if (id) {
config = await ConfigModel.findByPk(id); const configs = await db.select().from(schema.kvConfig).where(eq(schema.kvConfig.id, id)).limit(1);
config = configs[0];
let keyIsChange = false; let keyIsChange = false;
if (rest?.key) { if (rest?.key) {
keyIsChange = rest.key !== config?.key; keyIsChange = rest.key !== config?.key;
@@ -48,50 +48,57 @@ app
} }
if (keyIsChange) { if (keyIsChange) {
const key = rest.key; const key = rest.key;
const keyConfig = await ConfigModel.findOne({ const keyConfigs = await db.select()
where: { .from(schema.kvConfig)
key, .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tuid)))
uid: tuid, .limit(1);
}, const keyConfig = keyConfigs[0];
});
if (keyConfig && keyConfig.id !== id) { if (keyConfig && keyConfig.id !== id) {
ctx.throw(403, 'key is already exists'); ctx.throw(403, 'key is already exists');
} }
} }
await config.update({ const updated = await db.update(schema.kvConfig)
data: { .set({
...config.data, data: data,
...data,
},
...rest, ...rest,
}); updatedAt: new Date().toISOString(),
if (config.data?.permission?.share === 'public') { })
.where(eq(schema.kvConfig.id, id))
.returning();
config = updated[0];
if ((config.data as any)?.permission?.share === 'public') {
await ShareConfigService.expireShareConfig(config.key, tokernUser.username); await ShareConfigService.expireShareConfig(config.key, tokernUser.username);
} }
ctx.body = config; ctx.body = config;
} else if (rest?.key) { } else if (rest?.key) {
// id 不存在key存在则属于更新key不能重复 // id 不存在key存在则属于更新key不能重复
const key = rest.key; const key = rest.key;
config = await ConfigModel.findOne({ const configs = await db.select()
where: { .from(schema.kvConfig)
key, .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tuid)))
uid: tuid, .limit(1);
}, config = configs[0];
});
if (config) { if (config) {
await config.update({ const updated = await db.update(schema.kvConfig)
data: { ...config.data, ...data }, .set({
data: data,
...rest, ...rest,
}); updatedAt: new Date().toISOString(),
})
.where(eq(schema.kvConfig.id, config.id))
.returning();
config = updated[0];
ctx.body = config; ctx.body = config;
} else { } else {
// 根据key创建一个配置 // 根据key创建一个配置
config = await ConfigModel.create({ const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
key, key,
...rest, ...rest,
data: data, data: data,
uid: tuid, uid: tuid,
}); }).returning();
config = inserted[0];
ctx.body = config; ctx.body = config;
} }
} }
@@ -106,22 +113,25 @@ app
const data = config.data; const data = config.data;
const hash = ossConfig.hash(data); const hash = ossConfig.hash(data);
if (config.hash !== hash) { if (config.hash !== hash) {
config.hash = hash; await db.update(schema.kvConfig)
await config.save({ .set({
fields: ['hash'], hash: hash,
}); updatedAt: new Date().toISOString(),
})
.where(eq(schema.kvConfig.id, config.id));
await ossConfig.putJsonObject(key, data); await ossConfig.putJsonObject(key, data);
} }
} }
if (config) return; if (config) return;
// id和key不存在。创建一个新的配置, 而且没有id的 // id和key不存在。创建一个新的配置, 而且没有id的
const newConfig = await ConfigModel.create({ const newConfig = await db.insert(schema.kvConfig).values({
id: nanoid(),
...rest, ...rest,
data: data, data: data,
uid: tuid, uid: tuid,
}); }).returning();
ctx.body = newConfig; ctx.body = newConfig[0];
}) })
.addTo(app); .addTo(app);
@@ -139,17 +149,17 @@ app
if (!id && !key) { if (!id && !key) {
ctx.throw(400, 'id or key is required'); ctx.throw(400, 'id or key is required');
} }
let config: ConfigModel; let config: any;
if (id) { if (id) {
config = await ConfigModel.findByPk(id); const configs = await db.select().from(schema.kvConfig).where(eq(schema.kvConfig.id, id)).limit(1);
config = configs[0];
} }
if (!config && key) { if (!config && key) {
config = await ConfigModel.findOne({ const configs = await db.select()
where: { .from(schema.kvConfig)
key, .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tuid)))
uid: tuid, .limit(1);
}, config = configs[0];
});
} }
if (!config) { if (!config) {
ctx.throw(404, 'config not found'); ctx.throw(404, 'config not found');
@@ -174,12 +184,9 @@ app
const tuid = tokernUser.id; const tuid = tokernUser.id;
const { id, key } = ctx.query?.data || {}; const { id, key } = ctx.query?.data || {};
if (id || key) { if (id || key) {
const search: any = id ? { id } : { key }; const search: any = id ? eq(schema.kvConfig.id, id) : eq(schema.kvConfig.key, key);
const config = await ConfigModel.findOne({ const configs = await db.select().from(schema.kvConfig).where(search).limit(1);
where: { const config = configs[0];
...search
},
});
if (config && config.uid === tuid) { if (config && config.uid === tuid) {
const key = config.key; const key = config.key;
const ossConfig = ConfigOssService.fromBase({ const ossConfig = ConfigOssService.fromBase({
@@ -193,7 +200,7 @@ app
await ossConfig.deleteObject(key); await ossConfig.deleteObject(key);
} catch (e) { } } catch (e) { }
} }
await config.destroy(); await db.delete(schema.kvConfig).where(eq(schema.kvConfig.id, config.id));
} else { } else {
ctx.throw(403, 'no permission'); ctx.throw(403, 'no permission');
} }

View File

@@ -1,7 +1,8 @@
import { useContextKey } from '@kevisual/context'; import { useContextKey } from '@kevisual/context';
import { sequelize } from '../../../modules/sequelize.ts';
import { DataTypes, Model } from 'sequelize';
import { Permission } from '@kevisual/permission'; import { Permission } from '@kevisual/permission';
import { eq, and } from 'drizzle-orm';
import { db, schema } from '../../../app.ts';
import { nanoid } from 'nanoid';
export interface ConfigData { export interface ConfigData {
key?: string; key?: string;
@@ -9,23 +10,24 @@ export interface ConfigData {
permission?: Permission; permission?: Permission;
} }
export type Config = Partial<InstanceType<typeof ConfigModel>>; export type Config = {
id: string;
title: string | null;
description: string | null;
tags: unknown;
key: string | null;
data: unknown;
uid: string | null;
hash: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
/** /**
* 用户配置 * 用户配置
*/ */
export class ConfigModel extends Model { export class ConfigModel {
declare id: string;
declare title: string;
declare description: string;
declare tags: string[];
/**
* @important 配置key 默认可以为空,如何设置了,必须要唯一。
*/
declare key: string;
declare data: ConfigData; // files
declare uid: string;
declare hash: string;
/** /**
* 获取用户配置 * 获取用户配置
* @param key 配置key * @param key 配置key
@@ -35,37 +37,60 @@ export class ConfigModel extends Model {
* @returns 配置 * @returns 配置
*/ */
static async getConfig(key: string, opts: { uid: string; defaultData?: any }) { static async getConfig(key: string, opts: { uid: string; defaultData?: any }) {
const [config, isNew] = await ConfigModel.findOrCreate({ const existing = await db.select()
where: { key, uid: opts.uid }, .from(schema.kvConfig)
defaults: { .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, opts.uid)))
.limit(1);
if (existing.length > 0) {
return {
config: existing[0],
isNew: false,
};
}
const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
key, key,
title: key, title: key,
uid: opts.uid, uid: opts.uid,
data: opts?.defaultData || {}, data: opts?.defaultData || {},
}, }).returning();
});
return { return {
config: config, config: inserted[0],
isNew, isNew: true,
}; };
} }
static async setConfig(key: string, opts: { uid: string; data: any }) { static async setConfig(key: string, opts: { uid: string; data: any }) {
let config = await ConfigModel.findOne({ const existing = await db.select()
where: { key, uid: opts.uid }, .from(schema.kvConfig)
}); .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, opts.uid)))
if (config) { .limit(1);
config.data = { ...config.data, ...opts.data };
await config.save(); if (existing.length > 0) {
const config = existing[0];
const updated = await db.update(schema.kvConfig)
.set({
data: { ...(config.data as any || {}), ...opts.data },
updatedAt: new Date().toISOString(),
})
.where(eq(schema.kvConfig.id, config.id))
.returning();
return updated[0];
} else { } else {
config = await ConfigModel.create({ const inserted = await db.insert(schema.kvConfig).values({
id: nanoid(),
title: key, title: key,
key, key,
uid: opts.uid, uid: opts.uid,
data: opts.data, data: opts.data,
}); }).returning();
return inserted[0];
} }
return config;
} }
/** /**
* 获取上传配置 * 获取上传配置
* @param key 配置key * @param key 配置key
@@ -82,7 +107,7 @@ export class ConfigModel extends Model {
uid: opts.uid, uid: opts.uid,
defaultData: defaultConfig, defaultData: defaultConfig,
}); });
const data = config.config.data; const data = config.config.data as any;
const prefix = `/${data.key}/${data.version}`; const prefix = `/${data.key}/${data.version}`;
return { return {
config: config.config, config: config.config,
@@ -90,6 +115,7 @@ export class ConfigModel extends Model {
prefix, prefix,
}; };
} }
static async setUploadConfig(opts: { uid: string; data: { key?: string; version?: string } }) { static async setUploadConfig(opts: { uid: string; data: { key?: string; version?: string } }) {
const config = await ConfigModel.setConfig('upload.json', { const config = await ConfigModel.setConfig('upload.json', {
uid: opts.uid, uid: opts.uid,
@@ -98,52 +124,5 @@ export class ConfigModel extends Model {
return config; return config;
} }
} }
ConfigModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
comment: 'id',
},
title: {
type: DataTypes.TEXT,
defaultValue: '',
},
key: {
type: DataTypes.TEXT,
defaultValue: '',
},
description: {
type: DataTypes.TEXT,
defaultValue: '',
},
tags: {
type: DataTypes.JSONB,
defaultValue: [],
},
hash: {
type: DataTypes.TEXT,
defaultValue: '',
},
data: {
type: DataTypes.JSONB,
defaultValue: {},
},
uid: {
type: DataTypes.UUID,
allowNull: true,
},
},
{
sequelize,
tableName: 'kv_config',
paranoid: true,
},
);
// ConfigModel.sync({ alter: true, logging: false }).catch((e) => {
// console.error('ConfigModel sync', e);
// });
useContextKey('ConfigModel', () => ConfigModel); useContextKey('ConfigModel', () => ConfigModel);

View File

@@ -1,10 +1,10 @@
import { ConfigModel, Config } from '../models/model.ts'; import { Config } from '../models/model.ts';
import { CustomError } from '@kevisual/router'; import { CustomError } from '@kevisual/router';
import { redis } from '@/app.ts'; import { redis, db, schema } from '@/app.ts';
import { User } from '@/models/user.ts'; import { eq, and } from 'drizzle-orm';
import { UserPermission, UserPermissionOptions } from '@kevisual/permission'; import { UserPermission, UserPermissionOptions } from '@kevisual/permission';
export class ShareConfigService extends ConfigModel { export class ShareConfigService {
/** /**
* 获取分享的配置 * 获取分享的配置
* @param key 配置的key * @param key 配置的key
@@ -22,26 +22,30 @@ export class ShareConfigService extends ConfigModel {
} }
const owner = username; const owner = username;
if (shareCacheConfig) { if (shareCacheConfig) {
const permission = new UserPermission({ permission: shareCacheConfig?.data?.permission, owner }); const permission = new UserPermission({ permission: (shareCacheConfig?.data as any)?.permission, owner });
const result = permission.checkPermissionSuccess(options); const result = permission.checkPermissionSuccess(options);
if (!result.success) { if (!result.success) {
throw new CustomError(403, 'no permission'); throw new CustomError(403, 'no permission');
} }
return shareCacheConfig; return shareCacheConfig;
} }
const user = await User.findOne({ const users = await db.select()
where: { username }, .from(schema.cfUser)
}); .where(eq(schema.cfUser.username, username))
.limit(1);
const user = users[0];
if (!user) { if (!user) {
throw new CustomError(404, 'user not found'); throw new CustomError(404, 'user not found');
} }
const config = await ConfigModel.findOne({ const configs = await db.select()
where: { key, uid: user.id }, .from(schema.kvConfig)
}); .where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, user.id)))
.limit(1);
const config = configs[0];
if (!config) { if (!config) {
throw new CustomError(404, 'config not found'); throw new CustomError(404, 'config not found');
} }
const permission = new UserPermission({ permission: config?.data?.permission, owner }); const permission = new UserPermission({ permission: (config?.data as any)?.permission, owner });
const result = permission.checkPermissionSuccess(options); const result = permission.checkPermissionSuccess(options);
if (!result.success) { if (!result.success) {
throw new CustomError(403, 'no permission'); throw new CustomError(403, 'no permission');

View File

@@ -13,8 +13,9 @@ app
const config = await ConfigModel.getUploadConfig({ const config = await ConfigModel.getUploadConfig({
uid: tokenUser.id, uid: tokenUser.id,
}); });
const key = config?.config?.data?.key || ''; const data: any = config?.config?.data || {};
const version = config?.config?.data?.version || ''; const key = data.key || '';
const version = data.version || '';
const username = tokenUser.username; const username = tokenUser.username;
const prefix = `${key}/${version}/`; const prefix = `${key}/${version}/`;
ctx.body = { ctx.body = {
@@ -35,7 +36,7 @@ app
}) })
.define(async (ctx) => { .define(async (ctx) => {
const { id } = ctx.state.tokenUser; const { id } = ctx.state.tokenUser;
const data = ctx.query.data || {}; const data = ctx.query?.data || {};
const { key, version } = data; const { key, version } = data;
if (!key && !version) { if (!key && !version) {
ctx.throw(400, 'key or version is required'); ctx.throw(400, 'key or version is required');

View File

@@ -2,7 +2,6 @@ import { app } from '@/app.ts';
import { getFileStat, getMinioList, deleteFile, updateFileStat, deleteFiles } from './module/get-minio-list.ts'; import { getFileStat, getMinioList, deleteFile, updateFileStat, deleteFiles } from './module/get-minio-list.ts';
import path from 'path'; import path from 'path';
import { CustomError } from '@kevisual/router'; import { CustomError } from '@kevisual/router';
import { get } from 'http';
import { callDetectAppVersion } from '../app-manager/export.ts'; import { callDetectAppVersion } from '../app-manager/export.ts';
/** /**

View File

@@ -1,7 +1,6 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { minioClient } from '../../../modules/minio.ts'; import { oss } from '@/modules/s3.ts';
import { bucketName } from '../../../modules/minio.ts'; import { StatObjectResult } from '@kevisual/oss';
import { BucketItemStat, CopyDestinationOptions, CopySourceOptions } from 'minio';
type MinioListOpt = { type MinioListOpt = {
prefix: string; prefix: string;
recursive?: boolean; recursive?: boolean;
@@ -20,33 +19,13 @@ export type MinioList = (MinioFile | MinioDirectory)[];
export const getMinioList = async <IS_FILE extends boolean>(opts: MinioListOpt): Promise<IS_FILE extends true ? MinioFile[] : MinioDirectory[]> => { export const getMinioList = async <IS_FILE extends boolean>(opts: MinioListOpt): Promise<IS_FILE extends true ? MinioFile[] : MinioDirectory[]> => {
const prefix = opts.prefix; const prefix = opts.prefix;
const recursive = opts.recursive ?? false; const recursive = opts.recursive ?? false;
const res = await new Promise((resolve, reject) => { const res = await oss.listObjects(prefix, { recursive });
let res: any[] = [];
let hasError = false;
minioClient
.listObjectsV2(bucketName, prefix, recursive)
.on('data', (data) => {
res.push(data);
})
.on('error', (err) => {
console.error('minio error', opts.prefix, err);
hasError = true;
})
.on('end', () => {
if (hasError) {
reject();
return;
} else {
resolve(res);
}
});
});
return res as IS_FILE extends true ? MinioFile[] : MinioDirectory[]; return res as IS_FILE extends true ? MinioFile[] : MinioDirectory[];
}; };
export const getFileStat = async (prefix: string, isFile?: boolean): Promise<BucketItemStat | null> => { export const getFileStat = async (prefix: string, isFile?: boolean): Promise<StatObjectResult | null> => {
try { try {
const obj = await minioClient.statObject(bucketName, prefix); const obj = await oss.statObject(prefix);
if (isFile && obj.size === 0) { if (isFile && obj?.size === 0) {
return null; return null;
} }
return obj; return obj;
@@ -69,10 +48,7 @@ export const deleteFile = async (prefix: string): Promise<{ code: number; messag
message: 'file not found', message: 'file not found',
}; };
} }
await minioClient.removeObject(bucketName, prefix, { await oss.deleteObject(prefix);
versionId: 'null',
forceDelete: true, // 强制删除
});
return { return {
code: 200, code: 200,
message: 'delete success', message: 'delete success',
@@ -89,7 +65,9 @@ export const deleteFile = async (prefix: string): Promise<{ code: number; messag
// 批量删除文件 // 批量删除文件
export const deleteFiles = async (prefixs: string[]): Promise<any> => { export const deleteFiles = async (prefixs: string[]): Promise<any> => {
try { try {
await minioClient.removeObjects(bucketName, prefixs); for (const prefix of prefixs) {
await oss.deleteObject(prefix);
}
return true; return true;
} catch (e) { } catch (e) {
console.error('delete Files Error not handle', e); console.error('delete Files Error not handle', e);
@@ -135,14 +113,9 @@ export const updateFileStat = async (
message?: string; message?: string;
}> => { }> => {
try { try {
const source = new CopySourceOptions({ Bucket: bucketName, Object: prefix }); const copyResult = await oss.replaceObject(prefix, {
const destination = new CopyDestinationOptions({ ...newMetadata
Bucket: bucketName,
Object: prefix,
UserMetadata: newMetadata,
MetadataDirective: 'REPLACE',
}); });
const copyResult = await minioClient.copyObject(source, destination);
console.log('copyResult', copyResult); console.log('copyResult', copyResult);
console.log(`Metadata for ${prefix} updated successfully.`); console.log(`Metadata for ${prefix} updated successfully.`);
return { return {
@@ -171,25 +144,16 @@ export const mvUserAToUserB = async (usernameA: string, usernameB: string, clear
const newPrefix = `${usernameB}/`; const newPrefix = `${usernameB}/`;
const listSource = await getMinioList<true>({ prefix: oldPrefix, recursive: true }); const listSource = await getMinioList<true>({ prefix: oldPrefix, recursive: true });
for (const item of listSource) { for (const item of listSource) {
const source = new CopySourceOptions({ Bucket: bucketName, Object: item.name });
const stat = await getFileStat(item.name);
const newName = item.name.slice(oldPrefix.length); const newName = item.name.slice(oldPrefix.length);
// @ts-ignore await oss.copyObject(item.name, `${newPrefix}${newName}`);
const metadata = stat?.userMetadata || stat.metaData;
const destination = new CopyDestinationOptions({
Bucket: bucketName,
Object: `${newPrefix}${newName}`,
UserMetadata: metadata,
MetadataDirective: 'COPY',
});
await minioClient.copyObject(source, destination);
} }
if (clearOldUser) { if (clearOldUser) {
const files = await getMinioList<true>({ prefix: oldPrefix, recursive: true }); const files = await getMinioList<true>({ prefix: oldPrefix, recursive: true });
for (const file of files) { for (const file of files) {
await minioClient.removeObject(bucketName, file.name); await oss.deleteObject(file.name);
} }
} }
console.log(`移动 ${usernameA} to ${usernameB} success`);
}; };
export const backupUserA = async (usernameA: string, id: string, backName?: string) => { export const backupUserA = async (usernameA: string, id: string, backName?: string) => {
const today = backName || dayjs().format('YYYY-MM-DD-HH-mm'); const today = backName || dayjs().format('YYYY-MM-DD-HH-mm');
@@ -202,11 +166,12 @@ export const backupUserA = async (usernameA: string, id: string, backName?: stri
for (const item of deleteBackup) { for (const item of deleteBackup) {
const files = await getMinioList<true>({ prefix: item.prefix, recursive: true }); const files = await getMinioList<true>({ prefix: item.prefix, recursive: true });
for (const file of files) { for (const file of files) {
await minioClient.removeObject(bucketName, file.name); await oss.deleteObject(file.name);
} }
} }
} }
await mvUserAToUserB(usernameA, backupPrefix, false); await mvUserAToUserB(usernameA, backupPrefix, false);
console.log(`Backup user ${usernameA} to ${backupPrefix} success`);
}; };
/** /**
* 删除用户 * 删除用户
@@ -215,6 +180,6 @@ export const backupUserA = async (usernameA: string, id: string, backName?: stri
export const deleteUser = async (username: string) => { export const deleteUser = async (username: string) => {
const list = await getMinioList<true>({ prefix: `${username}/`, recursive: true }); const list = await getMinioList<true>({ prefix: `${username}/`, recursive: true });
for (const item of list) { for (const item of list) {
await minioClient.removeObject(bucketName, item.name); await oss.deleteObject(item.name);
} }
}; };

View File

@@ -0,0 +1,144 @@
import { desc, eq, count, or, like, and } from 'drizzle-orm';
import { schema, app, db } from '@/app.ts'
// 获取 flowme-channel 列表
app.route({
path: 'flowme-channel',
key: 'list',
middleware: ['auth'],
description: '获取 flowme-channel 列表',
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const uid = tokenUser.id;
const { page = 1, pageSize = 20, search, sort = 'DESC' } = ctx.query || {};
const offset = (page - 1) * pageSize;
const orderByField = sort === 'ASC' ? schema.flowmeChannels.updatedAt : desc(schema.flowmeChannels.updatedAt);
let whereCondition = eq(schema.flowmeChannels.uid, uid);
if (search) {
whereCondition = and(
eq(schema.flowmeChannels.uid, uid),
or(
like(schema.flowmeChannels.title, `%${search}%`),
like(schema.flowmeChannels.description, `%${search}%`)
)
);
}
const [list, totalCount] = await Promise.all([
db.select()
.from(schema.flowmeChannels)
.where(whereCondition)
.limit(pageSize)
.offset(offset)
.orderBy(orderByField),
db.select({ count: count() })
.from(schema.flowmeChannels)
.where(whereCondition)
]);
ctx.body = {
list,
pagination: {
page,
current: page,
pageSize,
total: totalCount[0]?.count || 0,
},
};
return ctx;
}).addTo(app);
// 创建或更新 flowme-channel
const channelUpdate = `创建或更新一个 flowme-channel, 参数定义:
title: 标题, 必填
description: 描述, 选填
tags: 标签, 数组, 选填
link: 链接, 选填
data: 数据, 对象, 选填
color: 颜色, 选填, 默认 #007bff
`;
app.route({
path: 'flowme-channel',
key: 'update',
middleware: ['auth'],
description: channelUpdate,
}).define(async (ctx) => {
const { id, uid, updatedAt, createdAt, ...rest } = ctx.query.data || {};
const tokenUser = ctx.state.tokenUser;
let channel;
if (!id) {
channel = await db.insert(schema.flowmeChannels).values({
title: rest.title || '',
description: rest.description || '',
tags: rest.tags || [],
link: rest.link || '',
data: rest.data || {},
color: rest.color || '#007bff',
uid: tokenUser.id,
}).returning();
} else {
const existing = await db.select().from(schema.flowmeChannels).where(eq(schema.flowmeChannels.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '没有找到对应的 channel');
}
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限更新该 channel');
}
channel = await db.update(schema.flowmeChannels).set({
title: rest.title,
description: rest.description,
tags: rest.tags,
link: rest.link,
data: rest.data,
color: rest.color,
}).where(eq(schema.flowmeChannels.id, id)).returning();
}
ctx.body = channel;
}).addTo(app);
// 删除 flowme-channel
app.route({
path: 'flowme-channel',
key: 'delete',
middleware: ['auth'],
description: '删除 flowme-channel, 参数: data.id 必填',
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query.data || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}
const existing = await db.select().from(schema.flowmeChannels).where(eq(schema.flowmeChannels.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '没有找到对应的 channel');
}
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限删除该 channel');
}
await db.delete(schema.flowmeChannels).where(eq(schema.flowmeChannels.id, id));
ctx.body = { success: true };
}).addTo(app);
// 获取单个 flowme-channel
app.route({
path: 'flowme-channel',
key: 'get',
middleware: ['auth'],
description: '获取单个 flowme-channel, 参数: data.id 必填',
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query.data || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}
const existing = await db.select().from(schema.flowmeChannels).where(eq(schema.flowmeChannels.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '没有找到对应的 channel');
}
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限查看该 channel');
}
ctx.body = existing[0];
}).addTo(app);

View File

@@ -0,0 +1,5 @@
import './list.ts'
// flowme channel 相关路由
import './flowme-channel/list.ts'

160
src/routes/flowme/list.ts Normal file
View File

@@ -0,0 +1,160 @@
import { desc, eq, count, or, like, and } from 'drizzle-orm';
import { schema, app, db } from '@/app.ts'
// 获取 flowme 列表
app.route({
path: 'flowme',
key: 'list',
middleware: ['auth'],
description: '获取 flowme 列表',
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const uid = tokenUser.id;
const { page = 1, pageSize = 20, search, channelId, sort = 'DESC' } = ctx.query || {};
const offset = (page - 1) * pageSize;
const orderByField = sort === 'ASC' ? schema.flowme.updatedAt : desc(schema.flowme.updatedAt);
let whereCondition = eq(schema.flowme.uid, uid);
if (search) {
whereCondition = and(
eq(schema.flowme.uid, uid),
or(
like(schema.flowme.title, `%${search}%`),
like(schema.flowme.description, `%${search}%`)
)
);
}
if (channelId) {
whereCondition = and(
whereCondition,
eq(schema.flowme.channelId, channelId)
);
}
const [list, totalCount] = await Promise.all([
db.select()
.from(schema.flowme)
.where(whereCondition)
.limit(pageSize)
.offset(offset)
.orderBy(orderByField),
db.select({ count: count() })
.from(schema.flowme)
.where(whereCondition)
]);
ctx.body = {
list,
pagination: {
page,
current: page,
pageSize,
total: totalCount[0]?.count || 0,
},
};
return ctx;
}).addTo(app);
// 创建或更新 flowme
const flowmeUpdate = `创建或更新一个 flowme, 参数定义:
title: 标题, 必填
description: 描述, 选填
tags: 标签, 数组, 选填
link: 链接, 选填
data: 数据, 对象, 选填
channelId: 频道ID, 选填
type: 类型, 选填
source: 来源, 选填
importance: 重要性等级, 数字, 选填
`;
app.route({
path: 'flowme',
key: 'update',
middleware: ['auth'],
description: flowmeUpdate,
}).define(async (ctx) => {
const { id, uid, updatedAt, createdAt, ...rest } = ctx.query.data || {};
const tokenUser = ctx.state.tokenUser;
let flowmeItem;
if (!id) {
flowmeItem = await db.insert(schema.flowme).values({
title: rest.title || '',
description: rest.description || '',
tags: rest.tags || [],
link: rest.link || '',
data: rest.data || {},
channelId: rest.channelId || null,
type: rest.type || '',
source: rest.source || '',
importance: rest.importance || 0,
uid: tokenUser.id,
}).returning();
} else {
const existing = await db.select().from(schema.flowme).where(eq(schema.flowme.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '没有找到对应的 flowme');
}
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限更新该 flowme');
}
flowmeItem = await db.update(schema.flowme).set({
title: rest.title,
description: rest.description,
tags: rest.tags,
link: rest.link,
data: rest.data,
channelId: rest.channelId,
type: rest.type,
source: rest.source,
importance: rest.importance,
isArchived: rest.isArchived,
}).where(eq(schema.flowme.id, id)).returning();
}
ctx.body = flowmeItem;
}).addTo(app);
// 删除 flowme
app.route({
path: 'flowme',
key: 'delete',
middleware: ['auth'],
description: '删除 flowme, 参数: data.id 必填',
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query.data || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}
const existing = await db.select().from(schema.flowme).where(eq(schema.flowme.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '没有找到对应的 flowme');
}
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限删除该 flowme');
}
await db.delete(schema.flowme).where(eq(schema.flowme.id, id));
ctx.body = { success: true };
}).addTo(app);
// 获取单个 flowme
app.route({
path: 'flowme',
key: 'get',
middleware: ['auth'],
description: '获取单个 flowme, 参数: data.id 必填',
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query.data || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}
const existing = await db.select().from(schema.flowme).where(eq(schema.flowme.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '没有找到对应的 flowme');
}
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限查看该 flowme');
}
ctx.body = existing[0];
}).addTo(app);

View File

@@ -0,0 +1,47 @@
import { schema, app, db } from '@/app.ts'
import { Client } from 'pg'
let pgClient: Client | null = null
async function startFlowmeListener() {
// 使用独立的数据库连接来监听
pgClient = new Client({
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/code_center',
})
await pgClient.connect()
console.log('🔌 已连接到 PostgreSQL 监听器')
// 订阅通知事件
pgClient.on('notification', (data) => {
if (!data.payload) return
try {
const parsed = JSON.parse(data.payload)
console.log('📥 收到新 flowme 创建通知:', parsed)
// 在这里处理你的业务逻辑
handleNewFlowme(parsed)
} catch (err) {
console.error('❌ 解析 flowme 通知失败:', err)
}
})
// 执行 LISTEN 命令订阅通道
await pgClient.query('LISTEN flowme_insert')
console.log('👂 开始监听 flowme_insert 通道...')
}
function handleNewFlowme(data: any) {
// 根据新创建的 flowme 数据执行相应操作
console.log('处理新 flowme:', data.id, data.title)
// 示例:可以通过 WebSocket 推送给前端
// wsServer.emit('flowme:created', data)
}
// 启动监听器(只启动一次)
if (!global.__flowmeListenerStarted) {
global.__flowmeListenerStarted = true
startFlowmeListener().catch(console.error)
}

View File

@@ -10,6 +10,8 @@ import './config/index.ts';
// import './file-listener/index.ts'; // import './file-listener/index.ts';
import './mark/index.ts';
import './light-code/index.ts'; import './light-code/index.ts';
import './ai/index.ts'; import './ai/index.ts';
@@ -19,3 +21,5 @@ import './prompts/index.ts'
import './views/index.ts'; import './views/index.ts';
import './query-views/index.ts'; import './query-views/index.ts';
import './flowme/index.ts'

View File

@@ -1,7 +1,8 @@
import { eq, desc, and, like, or } from 'drizzle-orm'; import { eq, desc, and, like, or } from 'drizzle-orm';
import { CustomError } from '@kevisual/router';
import { app, db, schema } from '../../app.ts'; import { app, db, schema } from '../../app.ts';
import { CustomError } from '@kevisual/router';
import { filter } from '@kevisual/js-filter'
import { z } from 'zod';
app app
.route({ .route({
path: 'light-code', path: 'light-code',
@@ -9,10 +10,22 @@ app
description: `获取轻代码列表,参数 description: `获取轻代码列表,参数
type: 代码类型light-code, ts`, type: 代码类型light-code, ts`,
middleware: ['auth'], middleware: ['auth'],
metadata: {
args: {
type: z.string().optional().describe('代码类型light-code, ts'),
search: z.string().optional().describe('搜索关键词,匹配标题和描述'),
filter: z
.string()
.optional()
.describe(
'过滤条件SQL like格式字符串例如WHERE tags LIKE \'%tag1%\' AND tags LIKE \'%tag2%\'',
),
}
}
}) })
.define(async (ctx) => { .define(async (ctx) => {
const tokenUser = ctx.state.tokenUser; const tokenUser = ctx.state.tokenUser;
const { type, search } = ctx.query || {}; const { type, search, filter: filterQuery } = ctx.query || {};
const conditions = [eq(schema.kvContainer.uid, tokenUser.id)]; const conditions = [eq(schema.kvContainer.uid, tokenUser.id)];
if (type) { if (type) {
conditions.push(eq(schema.kvContainer.type, type as string)); conditions.push(eq(schema.kvContainer.type, type as string));
@@ -34,6 +47,7 @@ app
type: schema.kvContainer.type, type: schema.kvContainer.type,
tags: schema.kvContainer.tags, tags: schema.kvContainer.tags,
data: schema.kvContainer.data, data: schema.kvContainer.data,
code: schema.kvContainer.code,
uid: schema.kvContainer.uid, uid: schema.kvContainer.uid,
createdAt: schema.kvContainer.createdAt, createdAt: schema.kvContainer.createdAt,
updatedAt: schema.kvContainer.updatedAt, updatedAt: schema.kvContainer.updatedAt,
@@ -42,7 +56,12 @@ app
.from(schema.kvContainer) .from(schema.kvContainer)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(schema.kvContainer.updatedAt)); .orderBy(desc(schema.kvContainer.updatedAt));
ctx.body = list; if (filterQuery) {
const filteredList = filter(list, filterQuery);
ctx.body = { list: filteredList }
} else {
ctx.body = { list };
}
return ctx; return ctx;
}) })
.addTo(app); .addTo(app);
@@ -81,6 +100,7 @@ app
path: 'light-code', path: 'light-code',
key: 'update', key: 'update',
middleware: ['auth'], middleware: ['auth'],
isDebug: true,
}) })
.define(async (ctx) => { .define(async (ctx) => {
const tokenUser = ctx.state.tokenUser; const tokenUser = ctx.state.tokenUser;
@@ -111,22 +131,34 @@ app
ctx.body = null; ctx.body = null;
} }
} else { } else {
try {
console.log('created', container, 'userId', tokenUser.id);
const [created] = await db const [created] = await db
.insert(schema.kvContainer) .insert(schema.kvContainer)
.values({ .values({
...container, title: container.title || '',
description: container.description || '',
type: container.type || 'light-code',
code: container.code || '',
data: container.data || {},
tags: container.tags || [],
hash: container.hash || '',
uid: tokenUser.id, uid: tokenUser.id,
}) })
.returning(); .returning();
ctx.body = created; ctx.body = created;
} catch (error) {
console.error('Error creating container:', error);
throw error;
}
} }
return ctx;
}) })
.addTo(app); .addTo(app);
app app
.route({ .route({
path: 'container', path: 'light-code',
key: 'delete', key: 'delete',
middleware: ['auth'], middleware: ['auth'],
}) })

1
src/routes/mark/index.ts Normal file
View File

@@ -0,0 +1 @@
import './list.ts';

308
src/routes/mark/list.ts Normal file
View File

@@ -0,0 +1,308 @@
import { eq, desc, and, like, or, count, sql } from 'drizzle-orm';
import { app, db, schema } from '../../app.ts';
import { MarkServices } from './services/mark.ts';
import dayjs from 'dayjs';
import { nanoid } from 'nanoid';
app
.route({
path: 'mark',
key: 'list',
description: 'mark list.',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
ctx.body = await MarkServices.getList({
uid: tokenUser.id,
query: ctx.query,
queryType: 'simple',
});
})
.addTo(app);
app
.route({
path: 'mark',
key: 'getVersion',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query;
if (id) {
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
ctx.body = {
version: Number(markModel.version),
updatedAt: markModel.updatedAt,
createdAt: markModel.createdAt,
id: markModel.id,
};
} else {
ctx.throw(400, 'id is required');
// const [markModel, created] = await MarkModel.findOrCreate({
// where: {
// uid: tokenUser.id,
// puid: tokenUser.uid,
// title: dayjs().format('YYYY-MM-DD'),
// },
// defaults: {
// title: dayjs().format('YYYY-MM-DD'),
// uid: tokenUser.id,
// markType: 'wallnote',
// tags: ['daily'],
// },
// });
// ctx.body = {
// version: Number(markModel.version),
// updatedAt: markModel.updatedAt,
// createdAt: markModel.createdAt,
// id: markModel.id,
// created: created,
// };
}
})
.addTo(app);
app
.route({
path: 'mark',
key: 'get',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query;
if (id) {
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
ctx.body = markModel;
} else {
ctx.throw(400, 'id is required');
// id 不存在获取当天的title为 日期的一条数据
// const [markModel, created] = await MarkModel.findOrCreate({
// where: {
// uid: tokenUser.id,
// puid: tokenUser.uid,
// title: dayjs().format('YYYY-MM-DD'),
// },
// defaults: {
// title: dayjs().format('YYYY-MM-DD'),
// uid: tokenUser.id,
// markType: 'wallnote',
// tags: ['daily'],
// uname: tokenUser.username,
// puid: tokenUser.uid,
// version: 1,
// },
// });
// ctx.body = markModel;
}
})
.addTo(app);
app
.route({
path: 'mark',
key: 'update',
middleware: ['auth'],
isDebug: true,
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id, createdAt, updatedAt, uid: _, puid: _2, uname: _3, data, ...rest } = ctx.query.data || {};
let markModel: any;
if (id) {
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
const version = Number(markModel.version) + 1;
const updated = await db.update(schema.microMark)
.set({
...rest,
data: {
...(markModel.data as any || {}),
...data,
},
version,
updatedAt: new Date().toISOString(),
})
.where(eq(schema.microMark.id, id))
.returning();
markModel = updated[0];
} else {
const inserted = await db.insert(schema.microMark).values({
id: nanoid(),
data: data || {},
...rest,
uname: tokenUser.username,
uid: tokenUser.id,
puid: tokenUser.uid,
}).returning();
markModel = inserted[0];
}
ctx.body = markModel;
})
.addTo(app);
app
.route({
path: 'mark',
key: 'updateNode',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const operate = ctx.query.operate || 'update';
const { id, node } = ctx.query.data || {};
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
// Update JSON node logic with Drizzle
const currentData = markModel.data as any || {};
const nodes = currentData.nodes || [];
const nodeIndex = nodes.findIndex((n: any) => n.id === node.id);
let updatedNodes;
if (operate === 'delete') {
updatedNodes = nodes.filter((n: any) => n.id !== node.id);
} else if (nodeIndex >= 0) {
updatedNodes = [...nodes];
updatedNodes[nodeIndex] = { ...nodes[nodeIndex], ...node };
} else {
updatedNodes = [...nodes, node];
}
const version = Number(markModel.version) + 1;
const updated = await db.update(schema.microMark)
.set({
data: { ...currentData, nodes: updatedNodes },
version,
updatedAt: new Date().toISOString(),
})
.where(eq(schema.microMark.id, id))
.returning();
ctx.body = updated[0];
})
.addTo(app);
app
.route({
path: 'mark',
key: 'updateNodes',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id, nodeOperateList } = ctx.query.data || {};
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
if (!nodeOperateList || !Array.isArray(nodeOperateList) || nodeOperateList.length === 0) {
ctx.throw(400, 'nodeOperateList is required');
}
if (nodeOperateList.some((item: any) => !item.node)) {
ctx.throw(400, 'nodeOperateList node is required');
}
// Update multiple JSON nodes logic with Drizzle
const currentData = markModel.data as any || {};
let nodes = currentData.nodes || [];
for (const item of nodeOperateList) {
const { node, operate = 'update' } = item;
const nodeIndex = nodes.findIndex((n: any) => n.id === node.id);
if (operate === 'delete') {
nodes = nodes.filter((n: any) => n.id !== node.id);
} else if (nodeIndex >= 0) {
nodes[nodeIndex] = { ...nodes[nodeIndex], ...node };
} else {
nodes.push(node);
}
}
const version = Number(markModel.version) + 1;
const updated = await db.update(schema.microMark)
.set({
data: { ...currentData, nodes },
version,
updatedAt: new Date().toISOString(),
})
.where(eq(schema.microMark.id, id))
.returning();
ctx.body = updated[0];
})
.addTo(app);
app
.route({
path: 'mark',
key: 'delete',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query;
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
const markModel = marks[0];
if (!markModel) {
ctx.throw(404, 'mark not found');
}
if (markModel.uid !== tokenUser.id) {
ctx.throw(403, 'no permission');
}
await db.delete(schema.microMark).where(eq(schema.microMark.id, id));
ctx.body = markModel;
})
.addTo(app);
app
.route({ path: 'mark', key: 'getMenu', description: '获取菜单', middleware: ['auth'] })
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const [rows, totalResult] = await Promise.all([
db.select({
id: schema.microMark.id,
title: schema.microMark.title,
summary: schema.microMark.summary,
tags: schema.microMark.tags,
thumbnail: schema.microMark.thumbnail,
link: schema.microMark.link,
createdAt: schema.microMark.createdAt,
updatedAt: schema.microMark.updatedAt,
}).from(schema.microMark).where(eq(schema.microMark.uid, tokenUser.id)),
db.select({ count: count() }).from(schema.microMark).where(eq(schema.microMark.uid, tokenUser.id))
]);
ctx.body = {
list: rows,
total: totalResult[0]?.count || 0,
};
})
.addTo(app);

View File

@@ -0,0 +1,327 @@
import { useContextKey } from '@kevisual/context';
import { nanoid, customAlphabet } from 'nanoid';
import { DataTypes, Model, ModelAttributes } from 'sequelize';
import type { Sequelize } from 'sequelize';
export const random = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
export type Mark = Partial<InstanceType<typeof MarkModel>>;
export type MarkData = {
md?: string; // markdown
mdList?: string[]; // markdown list
type?: string; // 类型 markdown | json | html | image | video | audio | code | link | file
data?: any;
key?: string; // 文件的名称, 唯一
push?: boolean; // 是否推送到elasticsearch
pushTime?: Date; // 推送时间
summary?: string; // 摘要
nodes?: MarkDataNode[]; // 节点
[key: string]: any;
};
export type MarkFile = {
id: string;
name: string;
url: string;
size: number;
type: 'self' | 'data' | 'generate'; // generate为生成文件
query: string; // 'data.nodes[id].content';
hash: string;
fileKey: string; // 文件的名称, 唯一
};
export type MarkDataNode = {
id?: string;
[key: string]: any;
};
export type MarkConfig = {
[key: string]: any;
};
export type MarkAuth = {
[key: string]: any;
};
/**
* 隐秘内容
* auth
* config
*
*/
export class MarkModel extends Model {
declare id: string;
declare title: string; // 标题可以ai生成
declare description: string; // 描述可以ai生成
declare cover: string; // 封面可以ai生成
declare thumbnail: string; // 缩略图
declare key: string; // 文件路径
declare markType: string; // markdown | json | html | image | video | audio | code | link | file
declare link: string; // 访问链接
declare tags: string[]; // 标签
declare summary: string; // 摘要, description的简化版
declare data: MarkData; // 数据
declare uid: string; // 操作用户的id
declare puid: string; // 父级用户的id, 真实用户
declare config: MarkConfig; // mark属于一定不会暴露的内容。
declare fileList: MarkFile[]; // 文件管理
declare uname: string; // 用户的名称, 或者着别名
declare markedAt: Date; // 标记时间
declare createdAt: Date;
declare updatedAt: Date;
declare version: number;
/**
* 加锁更新data中的node的节点通过node的id
* @param param0
*/
static async updateJsonNode(id: string, node: MarkDataNode, opts?: { operate?: 'update' | 'delete'; Model?: any; sequelize?: Sequelize }) {
const sequelize = opts?.sequelize || (await useContextKey('sequelize'));
const transaction = await sequelize.transaction(); // 开启事务
const operate = opts.operate || 'update';
const isUpdate = operate === 'update';
const Model = opts.Model || MarkModel;
try {
// 1. 获取当前的 JSONB 字段值(加锁)
const mark = await Model.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改
});
if (!mark) {
throw new Error('Mark not found');
}
// 2. 修改特定的数组元素
const data = mark.data as MarkData;
const items = data.nodes;
if (!node.id) {
node.id = random(12);
}
// 找到要更新的元素
const itemIndex = items.findIndex((item) => item.id === node.id);
if (itemIndex === -1) {
isUpdate && items.push(node);
} else {
if (isUpdate) {
items[itemIndex] = node;
} else {
items.splice(itemIndex, 1);
}
}
const version = Number(mark.version) + 1;
// 4. 更新 JSONB 字段
const result = await mark.update(
{
data: {
...data,
nodes: items,
},
version,
},
{ transaction },
);
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async updateJsonNodes(id: string, nodes: { node: MarkDataNode; operate?: 'update' | 'delete' }[], opts?: { Model?: any; sequelize?: Sequelize }) {
const sequelize = opts?.sequelize || (await useContextKey('sequelize'));
const transaction = await sequelize.transaction(); // 开启事务
const Model = opts?.Model || MarkModel;
try {
const mark = await Model.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改
});
if (!mark) {
throw new Error('Mark not found');
}
const data = mark.data as MarkData;
const _nodes = data.nodes || [];
// 过滤不在nodes中的节点
const blankNodes = nodes.filter((node) => !_nodes.find((n) => n.id === node.node.id)).map((node) => node.node);
// 更新或删除节点
const newNodes = _nodes
.map((node) => {
const nodeOperate = nodes.find((n) => n.node.id === node.id);
if (nodeOperate) {
if (nodeOperate.operate === 'delete') {
return null;
}
return nodeOperate.node;
}
return node;
})
.filter((node) => node !== null);
const version = Number(mark.version) + 1;
const result = await mark.update(
{
data: {
...data,
nodes: [...blankNodes, ...newNodes],
},
version,
},
{ transaction },
);
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async updateData(id: string, data: MarkData, opts: { Model?: any; sequelize?: Sequelize }) {
const sequelize = opts.sequelize || (await useContextKey('sequelize'));
const transaction = await sequelize.transaction(); // 开启事务
const Model = opts.Model || MarkModel;
const mark = await Model.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改
});
if (!mark) {
throw new Error('Mark not found');
}
const version = Number(mark.version) + 1;
const result = await mark.update(
{
...mark.data,
...data,
data: {
...mark.data,
...data,
},
version,
},
{ transaction },
);
await transaction.commit();
return result;
}
static async createNew(data: any, opts: { Model?: any; sequelize?: Sequelize }) {
const sequelize = opts.sequelize || (await useContextKey('sequelize'));
const transaction = await sequelize.transaction(); // 开启事务
const Model = opts.Model || MarkModel;
const result = await Model.create({ ...data, version: 1 }, { transaction });
await transaction.commit();
return result;
}
}
export type MarkInitOpts<T = any> = {
tableName: string;
sequelize?: Sequelize;
callInit?: (attribute: ModelAttributes) => ModelAttributes;
Model?: T extends typeof MarkModel ? T : typeof MarkModel;
};
export type Opts = {
sync?: boolean;
alter?: boolean;
logging?: boolean | ((...args: any) => any);
force?: boolean;
};
export const MarkMInit = async <T = any>(opts: MarkInitOpts<T>, sync?: Opts) => {
const sequelize = await useContextKey('sequelize');
opts.sequelize = opts.sequelize || sequelize;
const { callInit, Model, ...optsRest } = opts;
const modelAttribute = {
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
comment: 'id',
},
title: {
type: DataTypes.TEXT,
defaultValue: '',
},
key: {
type: DataTypes.TEXT, // 对应的minio的文件路径
defaultValue: '',
},
markType: {
type: DataTypes.TEXT,
defaultValue: 'md', // markdown | json | html | image | video | audio | code | link | file
comment: '类型',
},
description: {
type: DataTypes.TEXT,
defaultValue: '',
},
cover: {
type: DataTypes.TEXT,
defaultValue: '',
comment: '封面',
},
thumbnail: {
type: DataTypes.TEXT,
defaultValue: '',
comment: '缩略图',
},
link: {
type: DataTypes.TEXT,
defaultValue: '',
comment: '链接',
},
tags: {
type: DataTypes.JSONB,
defaultValue: [],
},
summary: {
type: DataTypes.TEXT,
defaultValue: '',
comment: '摘要',
},
config: {
type: DataTypes.JSONB,
defaultValue: {},
},
data: {
type: DataTypes.JSONB,
defaultValue: {},
},
fileList: {
type: DataTypes.JSONB,
defaultValue: [],
},
uname: {
type: DataTypes.STRING,
defaultValue: '',
comment: '用户的名称, 更新后的用户的名称',
},
version: {
type: DataTypes.INTEGER, // 更新刷新版本,多人协作
defaultValue: 1,
},
markedAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '标记时间',
},
uid: {
type: DataTypes.UUID,
allowNull: true,
},
puid: {
type: DataTypes.UUID,
allowNull: true,
},
};
const InitModel = Model || MarkModel;
InitModel.init(callInit ? callInit(modelAttribute) : modelAttribute, {
sequelize,
paranoid: true,
...optsRest,
});
if (sync && sync.sync) {
const { sync: _, ...rest } = sync;
MarkModel.sync({ alter: true, logging: false, ...rest }).catch((e) => {
console.error('MarkModel sync', e);
});
}
};
export const markModelInit = MarkMInit;
export const syncMarkModel = async (sync?: Opts, tableName = 'micro_mark') => {
const sequelize = await useContextKey('sequelize');
await MarkMInit({ sequelize, tableName }, sync);
};

5
src/routes/mark/model.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from '@kevisual/code-center-module/src/mark/mark-model.ts';
import { markModelInit, MarkModel, syncMarkModel } from '@kevisual/code-center-module/src/mark/mark-model.ts';
export { markModelInit, MarkModel };
syncMarkModel({ sync: true, alter: true, logging: false });

View File

@@ -0,0 +1,85 @@
import { eq, desc, asc, and, like, or, count } from 'drizzle-orm';
import { app, db, schema } from '../../../app.ts';
export class MarkServices {
static getList = async (opts: {
/** 查询用户的 */
uid?: string;
query?: {
page?: number;
pageSize?: number;
search?: string;
markType?: string;
sort?: string;
};
/**
* 查询类型
* simple: 简单查询 默认
*/
queryType?: string;
}) => {
const { uid, query = {} } = opts;
const { page = 1, pageSize = 999, search, sort = 'DESC' } = query;
const conditions = [];
if (uid) {
conditions.push(eq(schema.microMark.uid, uid));
}
if (search) {
conditions.push(
or(
like(schema.microMark.title, `%${search}%`),
like(schema.microMark.summary, `%${search}%`)
)
);
}
if (opts.query?.markType) {
conditions.push(eq(schema.microMark.markType, opts.query.markType));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const queryType = opts.queryType || 'simple';
let selectFields: any = {};
if (queryType === 'simple') {
// Exclude data, config, cover, description
selectFields = {
id: schema.microMark.id,
title: schema.microMark.title,
tags: schema.microMark.tags,
uname: schema.microMark.uname,
uid: schema.microMark.uid,
createdAt: schema.microMark.createdAt,
updatedAt: schema.microMark.updatedAt,
thumbnail: schema.microMark.thumbnail,
link: schema.microMark.link,
summary: schema.microMark.summary,
markType: schema.microMark.markType,
puid: schema.microMark.puid,
deletedAt: schema.microMark.deletedAt,
version: schema.microMark.version,
fileList: schema.microMark.fileList,
key: schema.microMark.key,
};
}
const orderByField = sort === 'ASC' ? asc(schema.microMark.updatedAt) : desc(schema.microMark.updatedAt);
const [rows, totalResult] = await Promise.all([
queryType === 'simple'
? db.select(selectFields).from(schema.microMark).where(whereClause).orderBy(orderByField).limit(pageSize).offset((page - 1) * pageSize)
: db.select().from(schema.microMark).where(whereClause).orderBy(orderByField).limit(pageSize).offset((page - 1) * pageSize),
db.select({ count: count() }).from(schema.microMark).where(whereClause)
]);
return {
pagination: {
current: page,
pageSize,
total: totalResult[0]?.count || 0,
},
list: rows,
};
};
}

View File

@@ -1,5 +1,4 @@
import { minioClient } from '@/app.ts'; import { oss } from '@/app.ts';
import { bucketName } from '@/modules/minio.ts';
import { fileIsExist } from '@kevisual/use-config'; import { fileIsExist } from '@kevisual/use-config';
import { spawn, spawnSync } from 'child_process'; import { spawn, spawnSync } from 'child_process';
import { getFileStat, getMinioList, MinioFile } from '@/routes/file/index.ts'; import { getFileStat, getMinioList, MinioFile } from '@/routes/file/index.ts';
@@ -8,6 +7,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { appsPath } from '../lib/index.ts'; import { appsPath } from '../lib/index.ts';
import { installAppFromKey } from './manager.ts'; import { installAppFromKey } from './manager.ts';
import { Readable } from 'stream';
export type InstallAppOpts = { export type InstallAppOpts = {
needInstallDeps?: boolean; needInstallDeps?: boolean;
// minio中 // minio中
@@ -46,7 +46,7 @@ export const installApp = async (opts: InstallAppOpts) => {
if (!fileIsExist(dir)) { if (!fileIsExist(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
const fileStream = await minioClient.getObject(bucketName, `${name}`); const fileStream = (await oss.getObject(`${name}`)).Body as Readable;
const writeStream = fs.createWriteStream(outputPath); const writeStream = fs.createWriteStream(outputPath);
fileStream.pipe(writeStream); fileStream.pipe(writeStream);

View File

@@ -153,7 +153,9 @@ app
browser: someInfo['user-agent'], browser: someInfo['user-agent'],
host: someInfo.host, host: someInfo.host,
}); });
createCookie(token, ctx); createCookie({
token: token.accessToken
}, ctx);
ctx.body = token; ctx.body = token;
}) })
.addTo(app); .addTo(app);
@@ -259,7 +261,10 @@ app
const refreshToken = accessUser.oauthExpand?.refreshToken; const refreshToken = accessUser.oauthExpand?.refreshToken;
if (refreshToken) { if (refreshToken) {
const result = await User.oauth.refreshToken(refreshToken); const result = await User.oauth.refreshToken(refreshToken);
createCookie(result, ctx); createCookie({
token: result.accessToken
}, ctx);
ctx.body = result; ctx.body = result;
return; return;
} else if (accessUser) { } else if (accessUser) {
@@ -268,7 +273,9 @@ app
...accessUser.oauthExpand, ...accessUser.oauthExpand,
hasRefreshToken: true, hasRefreshToken: true,
}); });
createCookie(result, ctx); createCookie({
token: result.accessToken
}, ctx);
ctx.body = result; ctx.body = result;
return; return;
} }
@@ -323,13 +330,17 @@ app
if (orgsList.includes(username)) { if (orgsList.includes(username)) {
if (tokenUsername === username) { if (tokenUsername === username) {
const result = await User.oauth.resetToken(token); const result = await User.oauth.resetToken(token);
createCookie(result, ctx); createCookie({
token: result.accessToken,
}, ctx);
await User.oauth.delToken(token); await User.oauth.delToken(token);
ctx.body = result; ctx.body = result;
} else { } else {
const user = await User.findOne({ where: { username } }); const user = await User.findOne({ where: { username } });
const result = await user.createToken(userId, 'default'); const result = await user.createToken(userId, 'default');
createCookie(result, ctx); createCookie({
token: result.accessToken,
}, ctx);
ctx.body = result; ctx.body = result;
} }
} else { } else {
@@ -352,7 +363,9 @@ app
const result = await User.oauth.refreshToken(refreshToken); const result = await User.oauth.refreshToken(refreshToken);
if (result) { if (result) {
console.log('refreshToken result', result); console.log('refreshToken result', result);
createCookie(result, ctx); createCookie({
token: result.accessToken,
}, ctx);
ctx.body = result; ctx.body = result;
} else { } else {
ctx.throw(500, 'Refresh Token Failed, please login again'); ctx.throw(500, 'Refresh Token Failed, please login again');

View File

@@ -109,7 +109,9 @@ app
const token = JSON.parse(data); const token = JSON.parse(data);
if (token.accessToken) { if (token.accessToken) {
ctx.body = token; ctx.body = token;
createCookie(token, ctx); createCookie({
token: token.accessToken,
}, ctx);
} else { } else {
ctx.throw(500, 'Checked error Failed, login failed, please login again'); ctx.throw(500, 'Checked error Failed, login failed, please login again');
} }

View File

@@ -0,0 +1,44 @@
import { app, showMore, showRes } from './common.ts';
import crypto from 'node:crypto';
export const getStringHash = (str: string) => {
return crypto.createHash('md5').update(str).digest('hex');
}
const code = `// 这是一个示例代码文件
import {App} from '@kevisual/router';
const app = new App();
app.route({
path: 'hello',
description: 'LightCode 示例路由 2323232323',
metadata: {
tags: ['light-code', 'example'],
},
}).define(async (ctx) => {
console.log('tokenUser:', ctx.query?.tokenUser);
ctx.body = 'Hello from LightCode!';
}).addTo(app);
app.wait();`
const code2 = `const a = 1`
const res = await app.run({
path: 'light-code',
key: 'update',
payload: {
data: {
title: 'Demo Light Code',
description: '这是一个演示用的轻代码项目,包含一个简单的路由示例。',
type: 'light-code',
tags: ['demo', 'light-code'],
data: {},
code: code,
hash: getStringHash(code),
},
token: "st_idht7xpffhgu2eeh94zd8ze1t7ew3amy",
},
})
console.log('showMore', showMore(res));

View File

@@ -3,7 +3,9 @@ import '@/route.ts';
import { useConfig, useContextKey } from '@kevisual/context'; import { useConfig, useContextKey } from '@kevisual/context';
import { Query } from '@kevisual/query'; import { Query } from '@kevisual/query';
import util from 'node:util'; import util from 'node:util';
import dotenv from 'dotenv';
dotenv.config();
export { export {
app, app,
useContextKey useContextKey

View File

@@ -1,40 +0,0 @@
import { bucketName, minioClient } from '@/modules/minio.ts';
import { S3Error } from 'minio';
const main = async () => {
const res = await new Promise((resolve, reject) => {
let res: any[] = [];
let hasError = false;
minioClient
.listObjectsV2(bucketName, 'root/codeflow/0.0.1/')
.on('data', (data) => {
res.push(data);
})
.on('error', (err) => {
console.error('error', err);
hasError = true;
})
.on('end', () => {
if (hasError) {
reject();
return;
} else {
resolve(res);
}
});
});
console.log(res);
};
// main();
const main2 = async () => {
try {
const obj = await minioClient.statObject(bucketName, 'root/codeflow/0.0.1/README.md');
console.log(obj);
} catch (e) {
console.log('', e.message, '\n\r', e.code);
// console.error(e);
}
};
main2();

View File

@@ -1,10 +1,10 @@
process.env.NODE_ENV = 'development'; process.env.NODE_ENV = 'development';
// import { mvUserAToUserB, backupUserA } from '../routes/file/module/get-minio-list.ts'; import { mvUserAToUserB, backupUserA } from '../routes/file/module/get-minio-list.ts';
// mvUserAToUserB('demo', 'demo2'); // mvUserAToUserB('demo', 'demo2');
// backupUserA('demo', '123', '2025-04-02-16-00'); // backupUserA('demo', '123', '2026-01-31-16-00');
// backupUserA('demo', '123', '2025-04-02-16-01'); // backupUserA('demo', '123', '2025-04-02-16-01');
// backupUserA('demo', '123', '2025-04-02-16-02'); // backupUserA('demo', '123', '2025-04-02-16-02');
// backupUserA('demo', '123', '2025-04-02-16-03'); // backupUserA('demo', '123', '2025-04-02-16-03');

4
src/test/s3-stat.ts Normal file
View File

@@ -0,0 +1,4 @@
import { oss } from '@/modules/s3.ts'
const stat = await oss.statObject('root/codepod/0.0.3/index.html');
console.log('Object Stat:', stat);