@lobehub/lobehub 2.0.0-next.143 → 2.0.0-next.145
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/.cursor/rules/project-introduce.mdc +1 -1
- package/AGENTS.md +4 -0
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +18 -0
- package/docs/self-hosting/advanced/auth.mdx +21 -10
- package/docs/self-hosting/advanced/auth.zh-CN.mdx +21 -10
- package/package.json +2 -1
- package/packages/database/migrations/0054_better_auth_two_factor.sql +2 -0
- package/packages/database/src/core/migrations.json +1 -1
- package/packages/database/src/models/user.ts +25 -5
- package/scripts/migrateServerDB/errorHint.js +26 -0
- package/scripts/migrateServerDB/index.ts +5 -1
- package/src/envs/email.ts +11 -0
- package/src/libs/better-auth/email-templates/magic-link.ts +5 -5
- package/src/libs/better-auth/email-templates/reset-password.ts +4 -4
- package/src/libs/better-auth/email-templates/verification.ts +4 -4
- package/src/server/services/email/README.md +19 -0
- package/src/server/services/email/impls/index.ts +5 -1
- package/src/server/services/email/impls/resend/index.ts +120 -0
- package/src/server/services/email/index.test.ts +1 -1
- package/src/server/services/email/index.ts +9 -1
- package/src/server/services/file/impls/index.ts +3 -3
- package/src/server/services/file/impls/local.ts +35 -35
- package/src/server/services/file/impls/s3.ts +1 -1
- package/src/server/services/file/impls/type.ts +11 -11
- package/src/server/services/file/index.ts +12 -12
|
@@ -4,7 +4,7 @@ alwaysApply: true
|
|
|
4
4
|
|
|
5
5
|
## Project Description
|
|
6
6
|
|
|
7
|
-
You are developing an open-source, modern-design AI
|
|
7
|
+
You are developing an open-source, modern-design AI Agent Workspace: LobeHub(previous LobeChat).
|
|
8
8
|
|
|
9
9
|
Supported platforms:
|
|
10
10
|
|
package/AGENTS.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
This document serves as a comprehensive guide for all team members when developing LobeChat.
|
|
4
4
|
|
|
5
|
+
## Project Description
|
|
6
|
+
|
|
7
|
+
You are developing an open-source, modern-design AI Agent Workspace: LobeHub(previous LobeChat).
|
|
8
|
+
|
|
5
9
|
## Tech Stack
|
|
6
10
|
|
|
7
11
|
Built with modern technologies:
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.145](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.144...v2.0.0-next.145)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2025-12-02**</sup>
|
|
8
|
+
|
|
9
|
+
#### ✨ Features
|
|
10
|
+
|
|
11
|
+
- **misc**: Email provider support resend.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### What's improved
|
|
19
|
+
|
|
20
|
+
- **misc**: Email provider support resend, closes [#10557](https://github.com/lobehub/lobe-chat/issues/10557) ([7449b29](https://github.com/lobehub/lobe-chat/commit/7449b29))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
## [Version 2.0.0-next.144](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.143...v2.0.0-next.144)
|
|
31
|
+
|
|
32
|
+
<sup>Released on **2025-12-02**</sup>
|
|
33
|
+
|
|
34
|
+
#### 🐛 Bug Fixes
|
|
35
|
+
|
|
36
|
+
- **misc**: User email unique migration error.
|
|
37
|
+
|
|
38
|
+
<br/>
|
|
39
|
+
|
|
40
|
+
<details>
|
|
41
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
42
|
+
|
|
43
|
+
#### What's fixed
|
|
44
|
+
|
|
45
|
+
- **misc**: User email unique migration error, closes [#10548](https://github.com/lobehub/lobe-chat/issues/10548) ([ca2a1a2](https://github.com/lobehub/lobe-chat/commit/ca2a1a2))
|
|
46
|
+
|
|
47
|
+
</details>
|
|
48
|
+
|
|
49
|
+
<div align="right">
|
|
50
|
+
|
|
51
|
+
[](#readme-top)
|
|
52
|
+
|
|
53
|
+
</div>
|
|
54
|
+
|
|
5
55
|
## [Version 2.0.0-next.143](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.142...v2.0.0-next.143)
|
|
6
56
|
|
|
7
57
|
<sup>Released on **2025-12-02**</sup>
|
package/changelog/v1.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"children": {
|
|
4
|
+
"features": [
|
|
5
|
+
"Email provider support resend."
|
|
6
|
+
]
|
|
7
|
+
},
|
|
8
|
+
"date": "2025-12-02",
|
|
9
|
+
"version": "2.0.0-next.145"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"children": {
|
|
13
|
+
"fixes": [
|
|
14
|
+
"User email unique migration error."
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"date": "2025-12-02",
|
|
18
|
+
"version": "2.0.0-next.144"
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"children": {
|
|
4
22
|
"features": [
|
|
@@ -89,16 +89,27 @@ When configuring OAuth providers, use the following callback URL format:
|
|
|
89
89
|
|
|
90
90
|
### Email Service Configuration
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
| Environment Variable | Type
|
|
95
|
-
| ------------------------------------- |
|
|
96
|
-
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | Optional
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
92
|
+
Used by email verification, password reset, and magic-link delivery. Choose a provider, then fill the matching variables:
|
|
93
|
+
|
|
94
|
+
| Environment Variable | Type | Description |
|
|
95
|
+
| ------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
96
|
+
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | Optional | Set to `1` to require email verification before users can sign in |
|
|
97
|
+
| `EMAIL_SERVICE_PROVIDER` | Optional | Email provider selector: `nodemailer` (default, SMTP) or `resend` |
|
|
98
|
+
| `SMTP_HOST` | Required | SMTP server hostname (e.g., `smtp.gmail.com`). Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
|
|
99
|
+
| `SMTP_PORT` | Required | SMTP server port (usually `587` for TLS, `465` for SSL). Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
|
|
100
|
+
| `SMTP_SECURE` | Optional | `true` for SSL (port 465), `false` for TLS (port 587). Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
|
|
101
|
+
| `SMTP_USER` | Required | SMTP auth username. Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
|
|
102
|
+
| `SMTP_PASS` | Required | SMTP auth password. Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
|
|
103
|
+
| `RESEND_API_KEY` | Required | Resend API key. Required when `EMAIL_SERVICE_PROVIDER=resend` |
|
|
104
|
+
| `RESEND_FROM` | Recommended | Default sender address (e.g., `noreply@your-verified-domain.com`). Must be a domain verified in Resend. Used when `EMAIL_SERVICE_PROVIDER=resend` |
|
|
105
|
+
|
|
106
|
+
### Magic Link (Passwordless) Login
|
|
107
|
+
|
|
108
|
+
Enable BetterAuth magic-link login (depends on a working email provider above):
|
|
109
|
+
|
|
110
|
+
| Environment Variable | Type | Description |
|
|
111
|
+
| ------------------------------- | -------- | -------------------------------------------------- |
|
|
112
|
+
| `NEXT_PUBLIC_ENABLE_MAGIC_LINK` | Optional | Set to `1` to enable passwordless magic-link login |
|
|
102
113
|
|
|
103
114
|
<Callout type={'tip'}>
|
|
104
115
|
For detailed provider configuration, refer to the [Next Auth provider documentation](/docs/self-hosting/advanced/auth/next-auth) as most configurations are compatible, or visit the official [Better Auth documentation](https://www.better-auth.com/docs/introduction).
|
|
@@ -87,16 +87,27 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供一个更加安全
|
|
|
87
87
|
|
|
88
88
|
### 邮件服务配置
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
| 环境变量 | 类型 | 描述
|
|
93
|
-
| ------------------------------------- | -- |
|
|
94
|
-
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
90
|
+
用于邮箱验证、密码重置和魔法链接发送。先选择邮件服务,再填对应变量:
|
|
91
|
+
|
|
92
|
+
| 环境变量 | 类型 | 描述 |
|
|
93
|
+
| ------------------------------------- | -- | ----------------------------------------------------------------------------------------- |
|
|
94
|
+
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱 |
|
|
95
|
+
| `EMAIL_SERVICE_PROVIDER` | 可选 | 邮件服务选择:`nodemailer`(默认,SMTP)或 `resend` |
|
|
96
|
+
| `SMTP_HOST` | 必选 | SMTP 服务器主机名(如 `smtp.gmail.com`),仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
|
|
97
|
+
| `SMTP_PORT` | 必选 | SMTP 服务器端口(TLS 通常为 `587`,SSL 为 `465`),仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
|
|
98
|
+
| `SMTP_SECURE` | 可选 | SSL 设置为 `true`(端口 465),TLS 设置为 `false`(端口 587),仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
|
|
99
|
+
| `SMTP_USER` | 必选 | SMTP 认证用户名,仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
|
|
100
|
+
| `SMTP_PASS` | 必选 | SMTP 认证密码,仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
|
|
101
|
+
| `RESEND_API_KEY` | 必选 | Resend API Key,`EMAIL_SERVICE_PROVIDER=resend` 时必填 |
|
|
102
|
+
| `RESEND_FROM` | 推荐 | 默认发件人地址(如 `noreply@已验证域名`),需为 Resend 已验证域名下的邮箱,`EMAIL_SERVICE_PROVIDER=resend` 时使用 |
|
|
103
|
+
|
|
104
|
+
### 魔法链接(免密)登录
|
|
105
|
+
|
|
106
|
+
启用 BetterAuth 魔法链接登录(依赖上方已配置好的邮件服务):
|
|
107
|
+
|
|
108
|
+
| 环境变量 | 类型 | 描述 |
|
|
109
|
+
| ------------------------------- | -- | ----------------- |
|
|
110
|
+
| `NEXT_PUBLIC_ENABLE_MAGIC_LINK` | 可选 | 设置为 `1` 以启用魔法链接登录 |
|
|
100
111
|
|
|
101
112
|
<Callout type={'tip'}>
|
|
102
113
|
详细的提供商配置可参考 [Next Auth 提供商文档](/zh/docs/self-hosting/advanced/auth/next-auth)(大部分配置兼容),或访问官方 [Better Auth 文档](https://www.better-auth.com/docs/introduction)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.145",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -285,6 +285,7 @@
|
|
|
285
285
|
"remark": "^15.0.1",
|
|
286
286
|
"remark-gfm": "^4.0.1",
|
|
287
287
|
"remark-html": "^16.0.1",
|
|
288
|
+
"resend": "^6.5.2",
|
|
288
289
|
"resolve-accept-language": "^3.1.15",
|
|
289
290
|
"rtl-detect": "^1.1.2",
|
|
290
291
|
"semver": "^7.7.3",
|
|
@@ -35,6 +35,8 @@ CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verifications" USIN
|
|
|
35
35
|
DO $$
|
|
36
36
|
BEGIN
|
|
37
37
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_email_unique') THEN
|
|
38
|
+
-- Normalize empty emails so the unique constraint can be created safely
|
|
39
|
+
UPDATE "users" SET "email" = NULL WHERE "email" = '';
|
|
38
40
|
ALTER TABLE "users" ADD CONSTRAINT "users_email_unique" UNIQUE ("email");
|
|
39
41
|
END IF;
|
|
40
42
|
END $$;
|
|
@@ -884,7 +884,7 @@
|
|
|
884
884
|
"\nCREATE INDEX IF NOT EXISTS \"account_userId_idx\" ON \"accounts\" USING btree (\"user_id\");\n",
|
|
885
885
|
"\nCREATE INDEX IF NOT EXISTS \"auth_session_userId_idx\" ON \"auth_sessions\" USING btree (\"user_id\");\n",
|
|
886
886
|
"\nCREATE INDEX IF NOT EXISTS \"verification_identifier_idx\" ON \"verifications\" USING btree (\"identifier\");\n",
|
|
887
|
-
"\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_email_unique') THEN\n ALTER TABLE \"users\" ADD CONSTRAINT \"users_email_unique\" UNIQUE (\"email\");\n END IF;\nEND $$;\n",
|
|
887
|
+
"\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_email_unique') THEN\n UPDATE \"users\" SET \"email\" = NULL WHERE \"email\" = '';\n ALTER TABLE \"users\" ADD CONSTRAINT \"users_email_unique\" UNIQUE (\"email\");\n END IF;\nEND $$;\n",
|
|
888
888
|
"\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_phone_number_unique') THEN\n ALTER TABLE \"users\" ADD CONSTRAINT \"users_phone_number_unique\" UNIQUE (\"phone_number\");\n END IF;\nEND $$;\n"
|
|
889
889
|
],
|
|
890
890
|
"bps": true,
|
|
@@ -149,9 +149,11 @@ export class UserModel {
|
|
|
149
149
|
};
|
|
150
150
|
|
|
151
151
|
updateUser = async (value: Partial<UserItem>) => {
|
|
152
|
+
const nextValue = UserModel.normalizeUniqueUserFields(value);
|
|
153
|
+
|
|
152
154
|
return this.db
|
|
153
155
|
.update(users)
|
|
154
|
-
.set({ ...
|
|
156
|
+
.set({ ...nextValue, updatedAt: new Date() })
|
|
155
157
|
.where(eq(users.id, this.userId));
|
|
156
158
|
};
|
|
157
159
|
|
|
@@ -193,6 +195,26 @@ export class UserModel {
|
|
|
193
195
|
.where(eq(users.id, this.userId));
|
|
194
196
|
};
|
|
195
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Normalize unique user fields so empty strings become null, keeping unique constraints safe.
|
|
200
|
+
*/
|
|
201
|
+
private static normalizeUniqueUserFields = <
|
|
202
|
+
T extends { email?: string | null; phone?: string | null },
|
|
203
|
+
>(
|
|
204
|
+
value: T,
|
|
205
|
+
) => {
|
|
206
|
+
const normalizedEmail =
|
|
207
|
+
typeof value.email === 'string' && value.email.trim() === '' ? null : value.email;
|
|
208
|
+
const normalizedPhone =
|
|
209
|
+
typeof value.phone === 'string' && value.phone.trim() === '' ? null : value.phone;
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
...value,
|
|
213
|
+
...(value.email !== undefined ? { email: normalizedEmail } : {}),
|
|
214
|
+
...(value.phone !== undefined ? { phone: normalizedPhone } : {}),
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
196
218
|
// Static method
|
|
197
219
|
static makeSureUserExist = async (db: LobeChatDatabase, userId: string) => {
|
|
198
220
|
await db.insert(users).values({ id: userId }).onConflictDoNothing();
|
|
@@ -205,10 +227,8 @@ export class UserModel {
|
|
|
205
227
|
if (!!user) return { duplicate: true };
|
|
206
228
|
}
|
|
207
229
|
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
.values({ ...params })
|
|
211
|
-
.returning();
|
|
230
|
+
const normalizedParams = this.normalizeUniqueUserFields(params);
|
|
231
|
+
const [user] = await db.insert(users).values(normalizedParams).returning();
|
|
212
232
|
|
|
213
233
|
return { duplicate: false, user };
|
|
214
234
|
};
|
|
@@ -24,7 +24,33 @@ DATABASE_DRIVER=node
|
|
|
24
24
|
if you have any other question, please open issue here: https://github.com/lobehub/lobe-chat/issues
|
|
25
25
|
`;
|
|
26
26
|
|
|
27
|
+
const DUPLICATE_EMAIL_HINT = `------------------------------------------------------------------------------------------
|
|
28
|
+
⚠️ Database migration failed due to duplicate email addresses in the users table.
|
|
29
|
+
|
|
30
|
+
The database schema requires each email to be unique, but multiple users currently share the same email value.
|
|
31
|
+
|
|
32
|
+
Recommended solutions (choose one and rerun the migration):
|
|
33
|
+
|
|
34
|
+
1) Update duplicate emails to make them unique: change the conflicting email addresses to another unique email address or just change them email to NULL
|
|
35
|
+
2) Remove duplicate user records (dangerously, only if safe to delete)
|
|
36
|
+
|
|
37
|
+
⚠️ IMPORTANT: Always backup your database before making any changes!
|
|
38
|
+
|
|
39
|
+
To find duplicate emails, run this query:
|
|
40
|
+
|
|
41
|
+
\`\`\`sql
|
|
42
|
+
SELECT email, COUNT(*) as count
|
|
43
|
+
FROM users
|
|
44
|
+
WHERE email IS NOT NULL
|
|
45
|
+
GROUP BY email
|
|
46
|
+
HAVING COUNT(*) > 1;
|
|
47
|
+
\`\`\`
|
|
48
|
+
|
|
49
|
+
If you need further assistance, please open an issue: https://github.com/lobehub/lobe-chat/issues
|
|
50
|
+
`;
|
|
51
|
+
|
|
27
52
|
module.exports = {
|
|
28
53
|
DB_FAIL_INIT_HINT,
|
|
54
|
+
DUPLICATE_EMAIL_HINT,
|
|
29
55
|
PGVECTOR_HINT,
|
|
30
56
|
};
|
|
@@ -4,7 +4,7 @@ import { migrate as nodeMigrate } from 'drizzle-orm/node-postgres/migrator';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
6
|
// @ts-ignore tsgo handle esm import cjs and compatibility issues
|
|
7
|
-
import { DB_FAIL_INIT_HINT, PGVECTOR_HINT } from './errorHint';
|
|
7
|
+
import { DB_FAIL_INIT_HINT, DUPLICATE_EMAIL_HINT, PGVECTOR_HINT } from './errorHint';
|
|
8
8
|
|
|
9
9
|
// Read the `.env` file if it exists, or a file specified by the
|
|
10
10
|
// dotenv_config_path parameter that's passed to Node.js
|
|
@@ -39,8 +39,12 @@ if (!isDesktop && connectionString) {
|
|
|
39
39
|
|
|
40
40
|
const errMsg = err.message as string;
|
|
41
41
|
|
|
42
|
+
const constraint = (err as { constraint?: string })?.constraint;
|
|
43
|
+
|
|
42
44
|
if (errMsg.includes('extension "vector" is not available')) {
|
|
43
45
|
console.info(PGVECTOR_HINT);
|
|
46
|
+
} else if (constraint === 'users_email_unique' || errMsg.includes('users_email_unique')) {
|
|
47
|
+
console.info(DUPLICATE_EMAIL_HINT);
|
|
44
48
|
} else if (errMsg.includes(`Cannot read properties of undefined (reading 'migrate')`)) {
|
|
45
49
|
console.info(DB_FAIL_INIT_HINT);
|
|
46
50
|
}
|
package/src/envs/email.ts
CHANGED
|
@@ -6,6 +6,9 @@ declare global {
|
|
|
6
6
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
7
7
|
namespace NodeJS {
|
|
8
8
|
interface ProcessEnv {
|
|
9
|
+
EMAIL_SERVICE_PROVIDER?: string;
|
|
10
|
+
RESEND_API_KEY?: string;
|
|
11
|
+
RESEND_FROM?: string;
|
|
9
12
|
SMTP_HOST?: string;
|
|
10
13
|
SMTP_PASS?: string;
|
|
11
14
|
SMTP_PORT?: string;
|
|
@@ -18,6 +21,9 @@ declare global {
|
|
|
18
21
|
export const getEmailConfig = () => {
|
|
19
22
|
return createEnv({
|
|
20
23
|
server: {
|
|
24
|
+
EMAIL_SERVICE_PROVIDER: z.enum(['nodemailer', 'resend']).optional(),
|
|
25
|
+
RESEND_API_KEY: z.string().optional(),
|
|
26
|
+
RESEND_FROM: z.string().optional(),
|
|
21
27
|
SMTP_HOST: z.string().optional(),
|
|
22
28
|
SMTP_PORT: z.coerce.number().optional(),
|
|
23
29
|
SMTP_SECURE: z.boolean().optional(),
|
|
@@ -30,6 +36,11 @@ export const getEmailConfig = () => {
|
|
|
30
36
|
SMTP_SECURE: process.env.SMTP_SECURE === 'true',
|
|
31
37
|
SMTP_USER: process.env.SMTP_USER,
|
|
32
38
|
SMTP_PASS: process.env.SMTP_PASS,
|
|
39
|
+
EMAIL_SERVICE_PROVIDER: process.env.EMAIL_SERVICE_PROVIDER
|
|
40
|
+
? process.env.EMAIL_SERVICE_PROVIDER.toLowerCase()
|
|
41
|
+
: undefined,
|
|
42
|
+
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
|
43
|
+
RESEND_FROM: process.env.RESEND_FROM,
|
|
33
44
|
},
|
|
34
45
|
});
|
|
35
46
|
};
|
|
@@ -18,7 +18,7 @@ export const getMagicLinkEmailTemplate = (params: { expiresInSeconds: number; ur
|
|
|
18
18
|
<head>
|
|
19
19
|
<meta charset="utf-8">
|
|
20
20
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
21
|
-
<title>Sign in to
|
|
21
|
+
<title>Sign in to LobeHub</title>
|
|
22
22
|
</head>
|
|
23
23
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f4f4f5; color: #1a1a1a;">
|
|
24
24
|
<!-- Container -->
|
|
@@ -28,7 +28,7 @@ export const getMagicLinkEmailTemplate = (params: { expiresInSeconds: number; ur
|
|
|
28
28
|
<div style="text-align: center; margin-bottom: 32px;">
|
|
29
29
|
<div style="display: inline-flex; align-items: center; justify-content: center; background-color: #ffffff; border-radius: 12px; padding: 8px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
|
|
30
30
|
<span style="font-size: 24px; line-height: 1; margin-right: 10px;">🤯</span>
|
|
31
|
-
<span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">
|
|
31
|
+
<span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeHub</span>
|
|
32
32
|
</div>
|
|
33
33
|
</div>
|
|
34
34
|
|
|
@@ -38,7 +38,7 @@ export const getMagicLinkEmailTemplate = (params: { expiresInSeconds: number; ur
|
|
|
38
38
|
<!-- Header -->
|
|
39
39
|
<div style="text-align: center; margin-bottom: 32px;">
|
|
40
40
|
<h1 style="color: #111827; font-size: 24px; font-weight: 700; margin: 0 0 12px 0; letter-spacing: -0.5px;">
|
|
41
|
-
Sign in to
|
|
41
|
+
Sign in to LobeHub
|
|
42
42
|
</h1>
|
|
43
43
|
<p style="color: #6b7280; font-size: 16px; margin: 0; line-height: 1.5;">
|
|
44
44
|
Click the link below to sign in to your account.
|
|
@@ -85,14 +85,14 @@ export const getMagicLinkEmailTemplate = (params: { expiresInSeconds: number; ur
|
|
|
85
85
|
<!-- Footer -->
|
|
86
86
|
<div style="text-align: center; margin-top: 32px;">
|
|
87
87
|
<p style="color: #a1a1aa; font-size: 13px; margin: 0;">
|
|
88
|
-
© ${new Date().getFullYear()}
|
|
88
|
+
© ${new Date().getFullYear()} LobeHub. All rights reserved.
|
|
89
89
|
</p>
|
|
90
90
|
</div>
|
|
91
91
|
</div>
|
|
92
92
|
</body>
|
|
93
93
|
</html>
|
|
94
94
|
`,
|
|
95
|
-
subject: 'Your
|
|
95
|
+
subject: 'Your LobeHub sign-in link',
|
|
96
96
|
text: `Use this link to sign in: ${url}\n\nThis link expires in ${expirationText}.`,
|
|
97
97
|
};
|
|
98
98
|
};
|
|
@@ -22,7 +22,7 @@ export const getResetPasswordEmailTemplate = (params: { url: string }) => {
|
|
|
22
22
|
<div style="text-align: center; margin-bottom: 32px;">
|
|
23
23
|
<div style="display: inline-flex; align-items: center; justify-content: center; background-color: #ffffff; border-radius: 12px; padding: 8px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
|
|
24
24
|
<span style="font-size: 24px; line-height: 1; margin-right: 10px;">🤯</span>
|
|
25
|
-
<span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">
|
|
25
|
+
<span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeHub</span>
|
|
26
26
|
</div>
|
|
27
27
|
</div>
|
|
28
28
|
|
|
@@ -42,7 +42,7 @@ export const getResetPasswordEmailTemplate = (params: { url: string }) => {
|
|
|
42
42
|
<!-- Content -->
|
|
43
43
|
<div style="color: #374151; font-size: 16px; line-height: 1.6;">
|
|
44
44
|
<p style="margin: 0 0 24px 0; text-align: center;">
|
|
45
|
-
You recently requested to reset your password for your
|
|
45
|
+
You recently requested to reset your password for your LobeHub account. Click the button below to proceed.
|
|
46
46
|
</p>
|
|
47
47
|
|
|
48
48
|
<!-- Button -->
|
|
@@ -78,14 +78,14 @@ export const getResetPasswordEmailTemplate = (params: { url: string }) => {
|
|
|
78
78
|
<!-- Footer -->
|
|
79
79
|
<div style="text-align: center; margin-top: 32px;">
|
|
80
80
|
<p style="color: #a1a1aa; font-size: 13px; margin: 0;">
|
|
81
|
-
© ${new Date().getFullYear()}
|
|
81
|
+
© ${new Date().getFullYear()} LobeHub. All rights reserved.
|
|
82
82
|
</p>
|
|
83
83
|
</div>
|
|
84
84
|
</div>
|
|
85
85
|
</body>
|
|
86
86
|
</html>
|
|
87
87
|
`,
|
|
88
|
-
subject: 'Reset Your Password -
|
|
88
|
+
subject: 'Reset Your Password - LobeHub',
|
|
89
89
|
text: `Reset your password by clicking this link: ${url}`,
|
|
90
90
|
};
|
|
91
91
|
};
|
|
@@ -33,7 +33,7 @@ export const getVerificationEmailTemplate = (params: {
|
|
|
33
33
|
<div style="text-align: center; margin-bottom: 32px;">
|
|
34
34
|
<div style="display: inline-flex; align-items: center; justify-content: center; background-color: #ffffff; border-radius: 12px; padding: 8px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
|
|
35
35
|
<span style="font-size: 24px; line-height: 1; margin-right: 10px;">🤯</span>
|
|
36
|
-
<span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">
|
|
36
|
+
<span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeHub</span>
|
|
37
37
|
</div>
|
|
38
38
|
</div>
|
|
39
39
|
|
|
@@ -55,7 +55,7 @@ export const getVerificationEmailTemplate = (params: {
|
|
|
55
55
|
${userName ? `<p style="margin: 0 0 16px 0;">Hi <strong>${userName}</strong>,</p>` : ''}
|
|
56
56
|
|
|
57
57
|
<p style="margin: 0 0 24px 0;">
|
|
58
|
-
Thanks for creating an account with
|
|
58
|
+
Thanks for creating an account with LobeHub. To access your account, please verify your email address by clicking the button below.
|
|
59
59
|
</p>
|
|
60
60
|
|
|
61
61
|
<!-- Button -->
|
|
@@ -95,14 +95,14 @@ export const getVerificationEmailTemplate = (params: {
|
|
|
95
95
|
<!-- Footer -->
|
|
96
96
|
<div style="text-align: center; margin-top: 32px;">
|
|
97
97
|
<p style="color: #a1a1aa; font-size: 13px; margin: 0;">
|
|
98
|
-
© 2025
|
|
98
|
+
© 2025 LobeHub. All rights reserved.
|
|
99
99
|
</p>
|
|
100
100
|
</div>
|
|
101
101
|
</div>
|
|
102
102
|
</body>
|
|
103
103
|
</html>
|
|
104
104
|
`,
|
|
105
|
-
subject: 'Verify Your Email -
|
|
105
|
+
subject: 'Verify Your Email - LobeHub',
|
|
106
106
|
text: `Please verify your email by clicking this link: ${url}\n\nThis link will expire in ${expirationText}.`,
|
|
107
107
|
};
|
|
108
108
|
};
|
|
@@ -86,6 +86,25 @@ SMTP_USER=your-username
|
|
|
86
86
|
SMTP_PASS=your-password
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
### Resend
|
|
90
|
+
|
|
91
|
+
If you prefer Resend, configure the following and initialize the service with `EmailImplType.Resend`:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
RESEND_API_KEY=your-resend-api-key
|
|
95
|
+
RESEND_FROM=noreply@example.com
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`RESEND_FROM` is used when `from` is not provided in the payload.
|
|
99
|
+
|
|
100
|
+
### Choose Provider by Environment
|
|
101
|
+
|
|
102
|
+
Set `EMAIL_SERVICE_PROVIDER` to `nodemailer` or `resend` to pick the default implementation without changing code:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
EMAIL_SERVICE_PROVIDER=resend
|
|
106
|
+
```
|
|
107
|
+
|
|
89
108
|
### Using Well-Known Services
|
|
90
109
|
|
|
91
110
|
You can also use well-known email services (Gmail, SendGrid, etc.):
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NodemailerImpl } from './nodemailer';
|
|
2
|
+
import { ResendImpl } from './resend';
|
|
2
3
|
import { EmailServiceImpl } from './type';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -6,8 +7,8 @@ import { EmailServiceImpl } from './type';
|
|
|
6
7
|
*/
|
|
7
8
|
export enum EmailImplType {
|
|
8
9
|
Nodemailer = 'nodemailer',
|
|
10
|
+
Resend = 'resend',
|
|
9
11
|
// Future providers can be added here:
|
|
10
|
-
// Resend = 'resend',
|
|
11
12
|
// SendGrid = 'sendgrid',
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -21,6 +22,9 @@ export const createEmailServiceImpl = (
|
|
|
21
22
|
case EmailImplType.Nodemailer: {
|
|
22
23
|
return new NodemailerImpl();
|
|
23
24
|
}
|
|
25
|
+
case EmailImplType.Resend: {
|
|
26
|
+
return new ResendImpl();
|
|
27
|
+
}
|
|
24
28
|
|
|
25
29
|
default: {
|
|
26
30
|
return new NodemailerImpl();
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import debug from 'debug';
|
|
3
|
+
import { Resend } from 'resend';
|
|
4
|
+
import type { CreateEmailOptions } from 'resend';
|
|
5
|
+
|
|
6
|
+
import { emailEnv } from '@/envs/email';
|
|
7
|
+
|
|
8
|
+
import { EmailPayload, EmailResponse, EmailServiceImpl } from '../type';
|
|
9
|
+
|
|
10
|
+
const log = debug('lobe-email:Resend');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resend implementation of the email service
|
|
14
|
+
*/
|
|
15
|
+
export class ResendImpl implements EmailServiceImpl {
|
|
16
|
+
private client: Resend;
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
if (!emailEnv.RESEND_API_KEY) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
'RESEND_API_KEY environment variable is required to use Resend email service. Please configure it in your .env file.',
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
this.client = new Resend(emailEnv.RESEND_API_KEY);
|
|
26
|
+
log('Initialized Resend client');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async sendMail(payload: EmailPayload): Promise<EmailResponse> {
|
|
30
|
+
const from = payload.from ?? emailEnv.RESEND_FROM;
|
|
31
|
+
const html = payload.html;
|
|
32
|
+
const text = payload.text;
|
|
33
|
+
|
|
34
|
+
if (!from) {
|
|
35
|
+
throw new TRPCError({
|
|
36
|
+
code: 'PRECONDITION_FAILED',
|
|
37
|
+
message: 'Missing sender address. Provide payload.from or RESEND_FROM environment variable.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!html && !text) {
|
|
42
|
+
throw new TRPCError({
|
|
43
|
+
code: 'PRECONDITION_FAILED',
|
|
44
|
+
message: 'Resend requires either html or text content in the email payload.',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const attachments = payload.attachments?.map((attachment) => {
|
|
49
|
+
if (attachment.content instanceof Buffer) {
|
|
50
|
+
return {
|
|
51
|
+
...attachment,
|
|
52
|
+
content: attachment.content.toString('base64'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return attachment;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
log('Sending email via Resend: %o', {
|
|
61
|
+
from,
|
|
62
|
+
subject: payload.subject,
|
|
63
|
+
to: payload.to,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const emailOptions: CreateEmailOptions = html
|
|
67
|
+
? {
|
|
68
|
+
attachments,
|
|
69
|
+
from,
|
|
70
|
+
html,
|
|
71
|
+
replyTo: payload.replyTo,
|
|
72
|
+
subject: payload.subject,
|
|
73
|
+
text,
|
|
74
|
+
to: payload.to,
|
|
75
|
+
}
|
|
76
|
+
: {
|
|
77
|
+
attachments,
|
|
78
|
+
from,
|
|
79
|
+
replyTo: payload.replyTo,
|
|
80
|
+
subject: payload.subject,
|
|
81
|
+
text: text!,
|
|
82
|
+
to: payload.to,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const { data, error } = await this.client.emails.send(emailOptions);
|
|
86
|
+
|
|
87
|
+
if (error) {
|
|
88
|
+
log.extend('error')('Failed to send email via Resend: %o', error);
|
|
89
|
+
throw new TRPCError({
|
|
90
|
+
cause: error,
|
|
91
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
92
|
+
message: `Failed to send email via Resend: ${error.message}`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!data?.id) {
|
|
97
|
+
log.extend('error')('Resend sendMail returned no message id: %o', data);
|
|
98
|
+
throw new TRPCError({
|
|
99
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
100
|
+
message: 'Failed to send email via Resend: missing message id',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
messageId: data.id,
|
|
106
|
+
};
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof TRPCError) {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
log.extend('error')('Unexpected Resend sendMail error: %o', error);
|
|
113
|
+
throw new TRPCError({
|
|
114
|
+
cause: error,
|
|
115
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
116
|
+
message: `Failed to send email via Resend: ${(error as Error).message}`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -26,7 +26,7 @@ describe('EmailService', () => {
|
|
|
26
26
|
|
|
27
27
|
describe('constructor', () => {
|
|
28
28
|
it('should create instance with default email implementation', () => {
|
|
29
|
-
expect(createEmailServiceImpl).toHaveBeenCalledWith(
|
|
29
|
+
expect(createEmailServiceImpl).toHaveBeenCalledWith(EmailImplType.Nodemailer);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it('should create instance with specified implementation type', () => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { emailEnv } from '@/envs/email';
|
|
1
2
|
|
|
2
3
|
import { EmailImplType, EmailPayload, EmailResponse, createEmailServiceImpl } from './impls';
|
|
3
4
|
import type { EmailServiceImpl } from './impls';
|
|
@@ -10,7 +11,14 @@ export class EmailService {
|
|
|
10
11
|
private emailImpl: EmailServiceImpl;
|
|
11
12
|
|
|
12
13
|
constructor(implType?: EmailImplType) {
|
|
13
|
-
|
|
14
|
+
// Avoid client-side access to server env when executed in browser-like test environments
|
|
15
|
+
const envImplType =
|
|
16
|
+
typeof window === 'undefined'
|
|
17
|
+
? (emailEnv.EMAIL_SERVICE_PROVIDER as EmailImplType | undefined)
|
|
18
|
+
: undefined;
|
|
19
|
+
const resolvedImplType = implType ?? envImplType ?? EmailImplType.Nodemailer;
|
|
20
|
+
|
|
21
|
+
this.emailImpl = createEmailServiceImpl(resolvedImplType);
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
/**
|
|
@@ -5,11 +5,11 @@ import { S3StaticFileImpl } from './s3';
|
|
|
5
5
|
import { FileServiceImpl } from './type';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Create file service module
|
|
9
|
+
* Automatically selects between S3 or desktop local file implementation based on environment
|
|
10
10
|
*/
|
|
11
11
|
export const createFileServiceModule = (): FileServiceImpl => {
|
|
12
|
-
//
|
|
12
|
+
// If in desktop application environment, use local file implementation
|
|
13
13
|
if (isDesktop) {
|
|
14
14
|
return new DesktopLocalFileImpl();
|
|
15
15
|
}
|
|
@@ -12,12 +12,12 @@ import { extractKeyFromUrlOrReturnOriginal } from './utils';
|
|
|
12
12
|
const log = debug('lobe-file:desktop-local');
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
15
|
+
* Desktop application local file service implementation
|
|
16
16
|
*/
|
|
17
17
|
export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Get local file URL
|
|
20
|
+
* Retrieve HTTP URL from main process via IPC
|
|
21
21
|
*/
|
|
22
22
|
private async getLocalFileUrl(key: string): Promise<string> {
|
|
23
23
|
try {
|
|
@@ -29,16 +29,16 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
32
|
+
* Create pre-signed upload URL (local version directly returns file path, may need further extension)
|
|
33
33
|
*/
|
|
34
34
|
async createPreSignedUrl(key: string): Promise<string> {
|
|
35
|
-
//
|
|
36
|
-
//
|
|
35
|
+
// In desktop application local file implementation, pre-signed URL is not needed
|
|
36
|
+
// Directly return the file path
|
|
37
37
|
return key;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
41
|
+
* Create pre-signed preview URL (local version accesses local files via HTTP path)
|
|
42
42
|
*/
|
|
43
43
|
async createPreSignedUrlForPreview(key: string): Promise<string> {
|
|
44
44
|
return this.getLocalFileUrl(key);
|
|
@@ -49,13 +49,13 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
52
|
+
* Delete files in batch
|
|
53
53
|
*/
|
|
54
54
|
async deleteFiles(keys: string[]): Promise<any> {
|
|
55
55
|
try {
|
|
56
56
|
if (!keys || keys.length === 0) return { success: true };
|
|
57
57
|
|
|
58
|
-
//
|
|
58
|
+
// Ensure all paths are valid desktop:// paths
|
|
59
59
|
const invalidKeys = keys.filter((key) => !key.startsWith('desktop://'));
|
|
60
60
|
if (invalidKeys.length > 0) {
|
|
61
61
|
console.error('Invalid desktop file paths:', invalidKeys);
|
|
@@ -65,7 +65,7 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
//
|
|
68
|
+
// Use electronIpcClient's dedicated method
|
|
69
69
|
return await electronIpcClient.deleteFiles(keys);
|
|
70
70
|
} catch (error) {
|
|
71
71
|
console.error('Failed to delete files:', error);
|
|
@@ -82,20 +82,20 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
85
|
+
* Get file byte array
|
|
86
86
|
*/
|
|
87
87
|
async getFileByteArray(key: string): Promise<Uint8Array> {
|
|
88
88
|
try {
|
|
89
|
-
//
|
|
89
|
+
// Get absolute file path from Electron
|
|
90
90
|
const filePath = await electronIpcClient.getFilePathById(key);
|
|
91
91
|
|
|
92
|
-
//
|
|
92
|
+
// Check if file exists
|
|
93
93
|
if (!existsSync(filePath)) {
|
|
94
94
|
console.error(`File not found: ${filePath}`);
|
|
95
95
|
return new Uint8Array();
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
//
|
|
98
|
+
// Read file content and convert to Uint8Array
|
|
99
99
|
const buffer = readFileSync(filePath);
|
|
100
100
|
return new Uint8Array(buffer);
|
|
101
101
|
} catch (e) {
|
|
@@ -105,20 +105,20 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
*
|
|
108
|
+
* Get file content
|
|
109
109
|
*/
|
|
110
110
|
async getFileContent(key: string): Promise<string> {
|
|
111
111
|
try {
|
|
112
|
-
//
|
|
112
|
+
// Get absolute file path from Electron
|
|
113
113
|
const filePath = await electronIpcClient.getFilePathById(key);
|
|
114
114
|
|
|
115
|
-
//
|
|
115
|
+
// Check if file exists
|
|
116
116
|
if (!existsSync(filePath)) {
|
|
117
117
|
console.error(`File not found: ${filePath}`);
|
|
118
118
|
return '';
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
//
|
|
121
|
+
// Read file content and convert to string
|
|
122
122
|
return readFileSync(filePath, 'utf8');
|
|
123
123
|
} catch (e) {
|
|
124
124
|
console.error('Failed to get file content:', e);
|
|
@@ -127,7 +127,7 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
130
|
+
* Get full file URL
|
|
131
131
|
*/
|
|
132
132
|
async getFullFileUrl(url?: string | null): Promise<string> {
|
|
133
133
|
if (!url) return '';
|
|
@@ -139,32 +139,32 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
142
|
+
* Upload content
|
|
143
|
+
* Note: This feature may require extension of Electron IPC interface
|
|
144
144
|
*/
|
|
145
145
|
async uploadContent(filePath: string, content: string): Promise<any> {
|
|
146
|
-
//
|
|
147
|
-
//
|
|
146
|
+
// Need to extend electronIpcClient to support uploading file content
|
|
147
|
+
// For example: return electronIpcClient.uploadContent(filePath, content);
|
|
148
148
|
console.warn('uploadContent not implemented for Desktop local file service', filePath, content);
|
|
149
149
|
return;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
153
|
+
* Extract key from full URL
|
|
154
|
+
* Extract desktop:// format path from HTTP URL
|
|
155
155
|
*/
|
|
156
156
|
getKeyFromFullUrl(url: string): string {
|
|
157
157
|
try {
|
|
158
158
|
const urlObj = new URL(url);
|
|
159
159
|
const pathSegments = urlObj.pathname.split('/').filter((segment) => segment !== '');
|
|
160
160
|
|
|
161
|
-
//
|
|
161
|
+
// Remove first path segment (desktop-file)
|
|
162
162
|
pathSegments.shift();
|
|
163
163
|
|
|
164
|
-
//
|
|
164
|
+
// Recombine remaining path segments
|
|
165
165
|
const filePath = pathSegments.join('/');
|
|
166
166
|
|
|
167
|
-
//
|
|
167
|
+
// Return desktop:// format path
|
|
168
168
|
return `desktop://${filePath}`;
|
|
169
169
|
} catch (e) {
|
|
170
170
|
console.error('[DesktopLocalFileImpl] Failed to extract key from URL:', e);
|
|
@@ -173,23 +173,23 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
|
-
*
|
|
176
|
+
* Upload media file
|
|
177
177
|
*/
|
|
178
178
|
async uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }> {
|
|
179
179
|
try {
|
|
180
|
-
//
|
|
180
|
+
// Convert Buffer to Base64 string
|
|
181
181
|
const content = buffer.toString('base64');
|
|
182
182
|
|
|
183
|
-
//
|
|
183
|
+
// Extract filename from key
|
|
184
184
|
const filename = path.basename(key);
|
|
185
185
|
|
|
186
|
-
//
|
|
186
|
+
// Calculate SHA256 hash of the file
|
|
187
187
|
const hash = sha256(buffer);
|
|
188
188
|
|
|
189
|
-
//
|
|
189
|
+
// Infer MIME type from file URL
|
|
190
190
|
const type = inferContentTypeFromImageUrl(key)!;
|
|
191
191
|
|
|
192
|
-
//
|
|
192
|
+
// Construct upload parameters
|
|
193
193
|
const uploadParams = {
|
|
194
194
|
content,
|
|
195
195
|
filename,
|
|
@@ -198,7 +198,7 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
|
198
198
|
type,
|
|
199
199
|
};
|
|
200
200
|
|
|
201
|
-
//
|
|
201
|
+
// Call electronIpcClient to upload file
|
|
202
202
|
const result = await electronIpcClient.createFile(uploadParams);
|
|
203
203
|
|
|
204
204
|
if (!result.success) {
|
|
@@ -7,7 +7,7 @@ import { FileServiceImpl } from './type';
|
|
|
7
7
|
import { extractKeyFromUrlOrReturnOriginal } from './utils';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* S3-based file service implementation
|
|
11
11
|
*/
|
|
12
12
|
export class S3StaticFileImpl implements FileServiceImpl {
|
|
13
13
|
private readonly s3: S3;
|
|
@@ -1,54 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* File service implementation interface
|
|
3
3
|
*/
|
|
4
4
|
export interface FileServiceImpl {
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Create pre-signed upload URL
|
|
7
7
|
*/
|
|
8
8
|
createPreSignedUrl(key: string): Promise<string>;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Create pre-signed preview URL
|
|
12
12
|
*/
|
|
13
13
|
createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise<string>;
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* Delete file
|
|
17
17
|
*/
|
|
18
18
|
deleteFile(key: string): Promise<any>;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
21
|
+
* Delete files in batch
|
|
22
22
|
*/
|
|
23
23
|
deleteFiles(keys: string[]): Promise<any>;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
26
|
+
* Get file byte array
|
|
27
27
|
*/
|
|
28
28
|
getFileByteArray(key: string): Promise<Uint8Array>;
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Get file content
|
|
32
32
|
*/
|
|
33
33
|
getFileContent(key: string): Promise<string>;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
36
|
+
* Get full file URL
|
|
37
37
|
*/
|
|
38
38
|
getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string>;
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
41
|
+
* Extract key from full URL
|
|
42
42
|
*/
|
|
43
43
|
getKeyFromFullUrl(url: string): string;
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
*
|
|
46
|
+
* Upload content
|
|
47
47
|
*/
|
|
48
48
|
uploadContent(path: string, content: string): Promise<any>;
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
51
|
+
* Upload media file
|
|
52
52
|
*/
|
|
53
53
|
uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }>;
|
|
54
54
|
}
|
|
@@ -11,8 +11,8 @@ import { TempFileManager } from '@/server/utils/tempFileManager';
|
|
|
11
11
|
import { FileServiceImpl, createFileServiceModule } from './impls';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* File service class
|
|
15
|
+
* Provides file operation services using a modular implementation approach
|
|
16
16
|
*/
|
|
17
17
|
export class FileService {
|
|
18
18
|
private userId: string;
|
|
@@ -26,70 +26,70 @@ export class FileService {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* Delete file
|
|
30
30
|
*/
|
|
31
31
|
public async deleteFile(key: string) {
|
|
32
32
|
return this.impl.deleteFile(key);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
36
|
+
* Delete files in batch
|
|
37
37
|
*/
|
|
38
38
|
public async deleteFiles(keys: string[]) {
|
|
39
39
|
return this.impl.deleteFiles(keys);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
43
|
+
* Get file content
|
|
44
44
|
*/
|
|
45
45
|
public async getFileContent(key: string): Promise<string> {
|
|
46
46
|
return this.impl.getFileContent(key);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
50
|
+
* Get file byte array
|
|
51
51
|
*/
|
|
52
52
|
public async getFileByteArray(key: string): Promise<Uint8Array> {
|
|
53
53
|
return this.impl.getFileByteArray(key);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
57
|
+
* Create pre-signed upload URL
|
|
58
58
|
*/
|
|
59
59
|
public async createPreSignedUrl(key: string): Promise<string> {
|
|
60
60
|
return this.impl.createPreSignedUrl(key);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
64
|
+
* Create pre-signed preview URL
|
|
65
65
|
*/
|
|
66
66
|
public async createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise<string> {
|
|
67
67
|
return this.impl.createPreSignedUrlForPreview(key, expiresIn);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
*
|
|
71
|
+
* Upload content
|
|
72
72
|
*/
|
|
73
73
|
public async uploadContent(path: string, content: string) {
|
|
74
74
|
return this.impl.uploadContent(path, content);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
78
|
+
* Get full file URL
|
|
79
79
|
*/
|
|
80
80
|
public async getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string> {
|
|
81
81
|
return this.impl.getFullFileUrl(url, expiresIn);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
85
|
+
* Extract key from full URL
|
|
86
86
|
*/
|
|
87
87
|
public getKeyFromFullUrl(url: string): string {
|
|
88
88
|
return this.impl.getKeyFromFullUrl(url);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
92
|
+
* Upload media file
|
|
93
93
|
*/
|
|
94
94
|
public async uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }> {
|
|
95
95
|
return this.impl.uploadMedia(key, buffer);
|