@lobehub/chat 1.104.4 → 1.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- package/src/app/[variants]/(main)/profile/apikey/Client.tsx +209 -0
- package/src/app/[variants]/(main)/profile/apikey/features/ApiKeyDatePicker/index.tsx +39 -0
- package/src/app/[variants]/(main)/profile/apikey/features/ApiKeyDisplay/index.tsx +60 -0
- package/src/app/[variants]/(main)/profile/apikey/features/ApiKeyModal/index.tsx +58 -0
- package/src/app/[variants]/(main)/profile/apikey/features/EditableCell/index.tsx +223 -0
- package/src/app/[variants]/(main)/profile/apikey/features/index.ts +3 -0
- package/src/app/[variants]/(main)/profile/apikey/page.tsx +32 -0
- package/src/app/[variants]/(main)/profile/hooks/useCategory.tsx +12 -1
- package/src/app/[variants]/(main)/settings/_layout/Desktop/index.tsx +1 -1
- package/src/config/featureFlags/schema.ts +7 -0
- package/src/database/migrations/0029_add_apikey_manage.sql +16 -0
- package/src/database/migrations/meta/0029_snapshot.json +6166 -0
- package/src/database/migrations/meta/_journal.json +7 -0
- package/src/database/models/apiKey.ts +116 -0
- package/src/database/schemas/apiKey.ts +25 -0
- package/src/database/schemas/index.ts +1 -0
- package/src/database/schemas/rbac.ts +11 -1
- package/src/locales/default/auth.ts +54 -0
- package/src/server/routers/lambda/apiKey.ts +80 -0
- package/src/server/routers/lambda/index.ts +2 -0
- package/src/store/global/initialState.ts +1 -0
- package/src/store/serverConfig/selectors.test.ts +1 -0
- package/src/types/apiKey.ts +12 -0
- package/src/utils/apiKey.ts +60 -0
@@ -203,6 +203,13 @@
|
|
203
203
|
"when": 1752567402506,
|
204
204
|
"tag": "0028_oauth_handoffs",
|
205
205
|
"breakpoints": true
|
206
|
+
},
|
207
|
+
{
|
208
|
+
"idx": 29,
|
209
|
+
"version": "7",
|
210
|
+
"when": 1753201379817,
|
211
|
+
"tag": "0029_add_apikey_manage",
|
212
|
+
"breakpoints": true
|
206
213
|
}
|
207
214
|
],
|
208
215
|
"version": "6"
|
@@ -0,0 +1,116 @@
|
|
1
|
+
import { and, desc, eq } from 'drizzle-orm/expressions';
|
2
|
+
|
3
|
+
import { LobeChatDatabase } from '@/database/type';
|
4
|
+
import { generateApiKey, isApiKeyExpired, validateApiKeyFormat } from '@/utils/apiKey';
|
5
|
+
|
6
|
+
import { ApiKeyItem, NewApiKeyItem, apiKeys } from '../schemas';
|
7
|
+
|
8
|
+
type EncryptAPIKeyVaults = (keyVaults: string) => Promise<string>;
|
9
|
+
type DecryptAPIKeyVaults = (keyVaults: string) => Promise<{ plaintext: string }>;
|
10
|
+
|
11
|
+
const defaultSerialize = (s: string) => s;
|
12
|
+
|
13
|
+
export class ApiKeyModel {
|
14
|
+
private userId: string;
|
15
|
+
private db: LobeChatDatabase;
|
16
|
+
|
17
|
+
constructor(db: LobeChatDatabase, userId: string) {
|
18
|
+
this.userId = userId;
|
19
|
+
this.db = db;
|
20
|
+
}
|
21
|
+
|
22
|
+
create = async (
|
23
|
+
params: Omit<NewApiKeyItem, 'userId' | 'id' | 'key'>,
|
24
|
+
encryptor?: EncryptAPIKeyVaults,
|
25
|
+
) => {
|
26
|
+
const key = generateApiKey();
|
27
|
+
|
28
|
+
const encrypt = encryptor || defaultSerialize;
|
29
|
+
|
30
|
+
const encryptedKey = await encrypt(key);
|
31
|
+
|
32
|
+
const [result] = await this.db
|
33
|
+
.insert(apiKeys)
|
34
|
+
.values({ ...params, key: encryptedKey, userId: this.userId })
|
35
|
+
.returning();
|
36
|
+
|
37
|
+
return result;
|
38
|
+
};
|
39
|
+
|
40
|
+
delete = async (id: number) => {
|
41
|
+
return this.db.delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, this.userId)));
|
42
|
+
};
|
43
|
+
|
44
|
+
deleteAll = async () => {
|
45
|
+
return this.db.delete(apiKeys).where(eq(apiKeys.userId, this.userId));
|
46
|
+
};
|
47
|
+
|
48
|
+
query = async (decryptor?: DecryptAPIKeyVaults) => {
|
49
|
+
const results = await this.db.query.apiKeys.findMany({
|
50
|
+
orderBy: [desc(apiKeys.updatedAt)],
|
51
|
+
where: eq(apiKeys.userId, this.userId),
|
52
|
+
});
|
53
|
+
|
54
|
+
// 如果没有提供解密器,直接返回原始结果
|
55
|
+
if (!decryptor) {
|
56
|
+
return results;
|
57
|
+
}
|
58
|
+
|
59
|
+
// 对每个 API Key 的 key 字段进行解密
|
60
|
+
const decryptedResults = await Promise.all(
|
61
|
+
results.map(async (apiKey) => {
|
62
|
+
const decryptedKey = await decryptor(apiKey.key);
|
63
|
+
return {
|
64
|
+
...apiKey,
|
65
|
+
key: decryptedKey.plaintext,
|
66
|
+
};
|
67
|
+
}),
|
68
|
+
);
|
69
|
+
|
70
|
+
return decryptedResults;
|
71
|
+
};
|
72
|
+
|
73
|
+
findByKey = async (key: string, encryptor?: EncryptAPIKeyVaults) => {
|
74
|
+
if (!validateApiKeyFormat(key)) {
|
75
|
+
return null;
|
76
|
+
}
|
77
|
+
|
78
|
+
const encrypt = encryptor || defaultSerialize;
|
79
|
+
|
80
|
+
const encryptedKey = await encrypt(key);
|
81
|
+
|
82
|
+
return this.db.query.apiKeys.findFirst({
|
83
|
+
where: eq(apiKeys.key, encryptedKey),
|
84
|
+
});
|
85
|
+
};
|
86
|
+
|
87
|
+
validateKey = async (key: string) => {
|
88
|
+
const apiKey = await this.findByKey(key);
|
89
|
+
|
90
|
+
if (!apiKey) return false;
|
91
|
+
if (!apiKey.enabled) return false;
|
92
|
+
if (isApiKeyExpired(apiKey.expiresAt)) return false;
|
93
|
+
|
94
|
+
return true;
|
95
|
+
};
|
96
|
+
|
97
|
+
update = async (id: number, value: Partial<ApiKeyItem>) => {
|
98
|
+
return this.db
|
99
|
+
.update(apiKeys)
|
100
|
+
.set({ ...value, updatedAt: new Date() })
|
101
|
+
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, this.userId)));
|
102
|
+
};
|
103
|
+
|
104
|
+
findById = async (id: number) => {
|
105
|
+
return this.db.query.apiKeys.findFirst({
|
106
|
+
where: and(eq(apiKeys.id, id), eq(apiKeys.userId, this.userId)),
|
107
|
+
});
|
108
|
+
};
|
109
|
+
|
110
|
+
updateLastUsed = async (id: number) => {
|
111
|
+
return this.db
|
112
|
+
.update(apiKeys)
|
113
|
+
.set({ lastUsedAt: new Date() })
|
114
|
+
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, this.userId)));
|
115
|
+
};
|
116
|
+
}
|
@@ -0,0 +1,25 @@
|
|
1
|
+
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
2
|
+
import { boolean, integer, pgTable, text, varchar } from 'drizzle-orm/pg-core';
|
3
|
+
import { createInsertSchema } from 'drizzle-zod';
|
4
|
+
|
5
|
+
import { timestamps, timestamptz } from './_helpers';
|
6
|
+
import { users } from './user';
|
7
|
+
|
8
|
+
export const apiKeys = pgTable('api_keys', {
|
9
|
+
id: integer('id').primaryKey().generatedByDefaultAsIdentity(), // auto-increment primary key
|
10
|
+
name: varchar('name', { length: 256 }).notNull(), // name of the API key
|
11
|
+
key: varchar('key', { length: 256 }).notNull().unique(), // API key
|
12
|
+
enabled: boolean('enabled').default(true), // whether the API key is enabled
|
13
|
+
expiresAt: timestamptz('expires_at'), // expires time
|
14
|
+
lastUsedAt: timestamptz('last_used_at'), // last used time
|
15
|
+
userId: text('user_id')
|
16
|
+
.references(() => users.id, { onDelete: 'cascade' })
|
17
|
+
.notNull(), // belongs to user, when user is deleted, the API key will be deleted
|
18
|
+
|
19
|
+
...timestamps,
|
20
|
+
});
|
21
|
+
|
22
|
+
export const insertApiKeySchema = createInsertSchema(apiKeys);
|
23
|
+
|
24
|
+
export type ApiKeyItem = typeof apiKeys.$inferSelect;
|
25
|
+
export type NewApiKeyItem = typeof apiKeys.$inferInsert;
|
@@ -1,5 +1,14 @@
|
|
1
1
|
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
2
|
-
import {
|
2
|
+
import {
|
3
|
+
boolean,
|
4
|
+
index,
|
5
|
+
integer,
|
6
|
+
jsonb,
|
7
|
+
pgTable,
|
8
|
+
primaryKey,
|
9
|
+
text,
|
10
|
+
timestamp,
|
11
|
+
} from 'drizzle-orm/pg-core';
|
3
12
|
|
4
13
|
import { timestamps } from './_helpers';
|
5
14
|
import { users } from './user';
|
@@ -12,6 +21,7 @@ export const roles = pgTable('rbac_roles', {
|
|
12
21
|
description: text('description'), // Role description
|
13
22
|
isSystem: boolean('is_system').default(false).notNull(), // Whether it's a system role
|
14
23
|
isActive: boolean('is_active').default(true).notNull(), // Whether it's active
|
24
|
+
metadata: jsonb('metadata').default({}), // Role metadata
|
15
25
|
|
16
26
|
...timestamps,
|
17
27
|
});
|
@@ -1,4 +1,57 @@
|
|
1
1
|
export default {
|
2
|
+
apikey: {
|
3
|
+
display: {
|
4
|
+
autoGenerated: '自动生成',
|
5
|
+
copy: '复制',
|
6
|
+
copyError: '复制失败',
|
7
|
+
copySuccess: 'API Key 已复制到剪贴板',
|
8
|
+
enterPlaceholder: '请输入',
|
9
|
+
hide: '隐藏',
|
10
|
+
neverExpires: '永不过期',
|
11
|
+
neverUsed: '从未使用',
|
12
|
+
show: '显示',
|
13
|
+
},
|
14
|
+
form: {
|
15
|
+
fields: {
|
16
|
+
expiresAt: {
|
17
|
+
label: '过期时间',
|
18
|
+
placeholder: '永不过期',
|
19
|
+
},
|
20
|
+
name: {
|
21
|
+
label: '名称',
|
22
|
+
placeholder: '请输入 API Key 名称',
|
23
|
+
},
|
24
|
+
},
|
25
|
+
submit: '创建',
|
26
|
+
title: '创建 API Key',
|
27
|
+
},
|
28
|
+
list: {
|
29
|
+
actions: {
|
30
|
+
create: '创建 API Key',
|
31
|
+
delete: '删除',
|
32
|
+
deleteConfirm: {
|
33
|
+
actions: {
|
34
|
+
cancel: '取消',
|
35
|
+
ok: '确认',
|
36
|
+
},
|
37
|
+
content: '确认删除该 API Key 吗?',
|
38
|
+
title: '确认操作',
|
39
|
+
},
|
40
|
+
},
|
41
|
+
columns: {
|
42
|
+
actions: '操作',
|
43
|
+
expiresAt: '过期时间',
|
44
|
+
key: '密钥',
|
45
|
+
lastUsedAt: '最后使用时间',
|
46
|
+
name: '名称',
|
47
|
+
status: '启用状态',
|
48
|
+
},
|
49
|
+
title: 'API Key 列表',
|
50
|
+
},
|
51
|
+
validation: {
|
52
|
+
required: '内容不得为空',
|
53
|
+
},
|
54
|
+
},
|
2
55
|
date: {
|
3
56
|
prevMonth: '上个月',
|
4
57
|
recent30Days: '最近30天',
|
@@ -90,6 +143,7 @@ export default {
|
|
90
143
|
words: '累计字数',
|
91
144
|
},
|
92
145
|
tab: {
|
146
|
+
apikey: 'API Key 管理',
|
93
147
|
profile: '个人资料',
|
94
148
|
security: '安全',
|
95
149
|
stats: '数据统计',
|
@@ -0,0 +1,80 @@
|
|
1
|
+
import { z } from 'zod';
|
2
|
+
|
3
|
+
import { ApiKeyModel } from '@/database/models/apiKey';
|
4
|
+
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
5
|
+
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
6
|
+
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
|
7
|
+
|
8
|
+
const apiKeyProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
9
|
+
const { ctx } = opts;
|
10
|
+
|
11
|
+
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
|
12
|
+
|
13
|
+
return opts.next({
|
14
|
+
ctx: {
|
15
|
+
apiKeyModel: new ApiKeyModel(ctx.serverDB, ctx.userId),
|
16
|
+
gateKeeper,
|
17
|
+
},
|
18
|
+
});
|
19
|
+
});
|
20
|
+
|
21
|
+
export const apiKeyRouter = router({
|
22
|
+
createApiKey: apiKeyProcedure
|
23
|
+
.input(
|
24
|
+
z.object({
|
25
|
+
expiresAt: z.date().optional().nullable(),
|
26
|
+
name: z.string(),
|
27
|
+
}),
|
28
|
+
)
|
29
|
+
.mutation(async ({ input, ctx }) => {
|
30
|
+
return await ctx.apiKeyModel.create(input, ctx.gateKeeper.encrypt);
|
31
|
+
}),
|
32
|
+
|
33
|
+
deleteAllApiKeys: apiKeyProcedure.mutation(async ({ ctx }) => {
|
34
|
+
return ctx.apiKeyModel.deleteAll();
|
35
|
+
}),
|
36
|
+
|
37
|
+
deleteApiKey: apiKeyProcedure
|
38
|
+
.input(z.object({ id: z.number() }))
|
39
|
+
.mutation(async ({ input, ctx }) => {
|
40
|
+
return ctx.apiKeyModel.delete(input.id);
|
41
|
+
}),
|
42
|
+
|
43
|
+
getApiKey: apiKeyProcedure
|
44
|
+
.input(z.object({ apiKey: z.string() }))
|
45
|
+
.query(async ({ input, ctx }) => {
|
46
|
+
return ctx.apiKeyModel.findByKey(input.apiKey, ctx.gateKeeper.encrypt);
|
47
|
+
}),
|
48
|
+
|
49
|
+
getApiKeyById: apiKeyProcedure
|
50
|
+
.input(z.object({ id: z.number() }))
|
51
|
+
.query(async ({ input, ctx }) => {
|
52
|
+
return ctx.apiKeyModel.findById(input.id);
|
53
|
+
}),
|
54
|
+
|
55
|
+
getApiKeys: apiKeyProcedure.query(async ({ ctx }) => {
|
56
|
+
return ctx.apiKeyModel.query(ctx.gateKeeper.decrypt);
|
57
|
+
}),
|
58
|
+
|
59
|
+
updateApiKey: apiKeyProcedure
|
60
|
+
.input(
|
61
|
+
z.object({
|
62
|
+
id: z.number(),
|
63
|
+
value: z.object({
|
64
|
+
description: z.string().optional(),
|
65
|
+
enabled: z.boolean().optional(),
|
66
|
+
expiresAt: z.date().optional().nullable(),
|
67
|
+
name: z.string().optional(),
|
68
|
+
}),
|
69
|
+
}),
|
70
|
+
)
|
71
|
+
.mutation(async ({ input, ctx }) => {
|
72
|
+
return ctx.apiKeyModel.update(input.id, input.value);
|
73
|
+
}),
|
74
|
+
|
75
|
+
validateApiKey: apiKeyProcedure
|
76
|
+
.input(z.object({ key: z.string() }))
|
77
|
+
.query(async ({ input, ctx }) => {
|
78
|
+
return ctx.apiKeyModel.validateKey(input.key);
|
79
|
+
}),
|
80
|
+
});
|
@@ -6,6 +6,7 @@ import { publicProcedure, router } from '@/libs/trpc/lambda';
|
|
6
6
|
import { agentRouter } from './agent';
|
7
7
|
import { aiModelRouter } from './aiModel';
|
8
8
|
import { aiProviderRouter } from './aiProvider';
|
9
|
+
import { apiKeyRouter } from './apiKey';
|
9
10
|
import { chunkRouter } from './chunk';
|
10
11
|
import { configRouter } from './config';
|
11
12
|
import { documentRouter } from './document';
|
@@ -31,6 +32,7 @@ export const lambdaRouter = router({
|
|
31
32
|
agent: agentRouter,
|
32
33
|
aiModel: aiModelRouter,
|
33
34
|
aiProvider: aiProviderRouter,
|
35
|
+
apiKey: apiKeyRouter,
|
34
36
|
chunk: chunkRouter,
|
35
37
|
config: configRouter,
|
36
38
|
document: documentRouter,
|
@@ -0,0 +1,12 @@
|
|
1
|
+
export { type ApiKeyItem } from '@/database/schemas/apiKey';
|
2
|
+
|
3
|
+
export interface CreateApiKeyParams {
|
4
|
+
expiresAt?: Date | null;
|
5
|
+
name: string;
|
6
|
+
}
|
7
|
+
|
8
|
+
export interface UpdateApiKeyParams {
|
9
|
+
enabled?: boolean;
|
10
|
+
expiresAt?: Date | null;
|
11
|
+
name?: string;
|
12
|
+
}
|
@@ -0,0 +1,60 @@
|
|
1
|
+
// Global counter for additional uniqueness
|
2
|
+
let apiKeyCounter = 0;
|
3
|
+
|
4
|
+
/**
|
5
|
+
* Generate API Key
|
6
|
+
* Format: lb-{random}
|
7
|
+
* @returns Generated API Key
|
8
|
+
*/
|
9
|
+
export function generateApiKey(): string {
|
10
|
+
// Use high-resolution timestamp for better uniqueness
|
11
|
+
const timestamp = performance.now().toString(36).replaceAll('.', '');
|
12
|
+
|
13
|
+
// Generate multiple random components
|
14
|
+
const random1 = Math.random().toString(36).slice(2);
|
15
|
+
const random2 = Math.random().toString(36).slice(2);
|
16
|
+
const random3 = Math.random().toString(36).slice(2);
|
17
|
+
|
18
|
+
// Add a counter-based component for additional uniqueness
|
19
|
+
apiKeyCounter = (apiKeyCounter + 1) % 1_000_000;
|
20
|
+
const counter = apiKeyCounter.toString(36);
|
21
|
+
|
22
|
+
// Combine all components
|
23
|
+
const combined = (timestamp + random1 + random2 + random3 + counter).replaceAll(/[^\da-z]/g, '');
|
24
|
+
|
25
|
+
// Ensure we have enough entropy
|
26
|
+
let randomPart = combined.slice(0, 16);
|
27
|
+
|
28
|
+
// If we don't have enough characters, generate more
|
29
|
+
while (randomPart.length < 16) {
|
30
|
+
const additional = Math.random().toString(36).slice(2);
|
31
|
+
randomPart += additional;
|
32
|
+
}
|
33
|
+
|
34
|
+
// Take exactly 16 characters
|
35
|
+
randomPart = randomPart.slice(0, 16);
|
36
|
+
|
37
|
+
// Combine to form the final API Key
|
38
|
+
return `lb-${randomPart}`;
|
39
|
+
}
|
40
|
+
|
41
|
+
/**
|
42
|
+
* Check if API Key is expired
|
43
|
+
* @param expiresAt - Expiration time
|
44
|
+
* @returns Whether the key has expired
|
45
|
+
*/
|
46
|
+
export function isApiKeyExpired(expiresAt: Date | null): boolean {
|
47
|
+
if (!expiresAt) return false;
|
48
|
+
return new Date() > expiresAt;
|
49
|
+
}
|
50
|
+
|
51
|
+
/**
|
52
|
+
* Validate API Key format
|
53
|
+
* @param key - API Key to validate
|
54
|
+
* @returns Whether the key has a valid format
|
55
|
+
*/
|
56
|
+
export function validateApiKeyFormat(key: string): boolean {
|
57
|
+
// Check format: lb-{random}
|
58
|
+
const pattern = /^lb-[\da-f]{16}$/;
|
59
|
+
return pattern.test(key);
|
60
|
+
}
|