@lobehub/chat 1.104.5 → 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.
@@ -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,6 @@
1
1
  export * from './agent';
2
2
  export * from './aiInfra';
3
+ export * from './apiKey';
3
4
  export * from './asyncTask';
4
5
  export * from './document';
5
6
  export * from './file';
@@ -1,5 +1,14 @@
1
1
  /* eslint-disable sort-keys-fix/sort-keys-fix */
2
- import { boolean, index, integer, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
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,
@@ -40,6 +40,7 @@ export enum SettingsTabs {
40
40
  }
41
41
 
42
42
  export enum ProfileTabs {
43
+ APIKey = 'apikey',
43
44
  Profile = 'profile',
44
45
  Security = 'security',
45
46
  Stats = 'stats',
@@ -19,6 +19,7 @@ describe('featureFlagsSelectors', () => {
19
19
  expect(result).toEqual({
20
20
  enableWebrtc: false,
21
21
  isAgentEditable: false,
22
+ showApiKeyManage: false,
22
23
  enablePlugins: true,
23
24
  showCreateSession: true,
24
25
  showChangelog: true,
@@ -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
+ }