fix: handle MIME decoding and deploy workflow
This commit is contained in:
1 parent
f031612996
commit
207cd0c789
10 files changed
+204
-77
No files matched your search
+18
-18
@@ -10,38 +10,38 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
name: Deploy Auth Inbox Worker
|
||||
steps:
|
||||
# 步骤 1: 检出代码
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 步骤 2: 设置 Node.js 环境
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20' # 根据您的项目需求选择 Node.js 版本
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
# 步骤 3: 安装依赖项
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Web Dependencies
|
||||
run: npm --prefix web install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Web App
|
||||
run: npm run build:web
|
||||
run: pnpm run build:web
|
||||
|
||||
# 步骤 4: 部署 Worker
|
||||
- name: Deploy Backend for ${{ github.ref_name }}
|
||||
- name: Apply D1 Migrations
|
||||
run: |
|
||||
|
||||
# Write secrets.TOML to wrangler.toml file
|
||||
echo '${{ secrets.TOML }}' > wrangler.toml
|
||||
|
||||
# Deploy the worker using Wrangler
|
||||
npx wrangler deploy
|
||||
echo '${{ secrets.TOML }}' > wrangler.toml
|
||||
pnpm run db:migrate:remote
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
- name: Deploy Backend for ${{ github.ref_name }}
|
||||
run: |
|
||||
pnpm exec wrangler deploy
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -22,7 +22,7 @@ src/
|
||||
auth.ts # PBKDF2 / JWT / API key
|
||||
mail.ts # visibleMails — 唯一的权限过滤查询层
|
||||
classify.ts # LLM 提取 (prompt 加了 category)
|
||||
mime.ts # 旧版 MIME 工具原样迁移
|
||||
mime.ts # MIME/encoded-word 解码、HTML 去标签、推广邮件识别
|
||||
email/
|
||||
handler.ts # email() 逻辑, 写入 category
|
||||
rpcEmail.ts # 原样保留
|
||||
@@ -37,21 +37,21 @@ src/
|
||||
pnpm add hono @hono/mcp @modelcontextprotocol/sdk zod
|
||||
```
|
||||
|
||||
2. 设置 secret(JWT 签名用,随便一串长随机字符串):
|
||||
2. 设置 secret(JWT 签名用,随便一串长随机字符串)。生产环境不要把它写进 `wrangler.toml`:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 | wrangler secret put JWT_SECRET
|
||||
pnpm exec wrangler secret put JWT_SECRET
|
||||
```
|
||||
|
||||
3. wrangler.toml 里删掉 `FrontEndAdminID` / `FrontEndAdminPassword`(已被 users 表取代),其余 vars 不变。
|
||||
3. `FrontEndAdminID` / `FrontEndAdminPassword` 已被 users 表取代,不再需要配置。首次部署后在登录页创建第一个 admin。
|
||||
|
||||
4. 迁移数据库。首次使用 wrangler migrations 需要先建 `migrations/` 目录并让 wrangler 认领 0001 为已执行(现有库已经有这两张表,0001 是幂等的所以直接 apply 也安全):
|
||||
4. 迁移数据库。`pnpm run deploy` 会自动先执行远端 D1 migrations 再部署 Worker;也可以单独执行:
|
||||
|
||||
```bash
|
||||
pnpm run db:migrate:remote
|
||||
```
|
||||
|
||||
5. 部署后创建第一个 admin(users 表为空时此端点才可用,之后永久关闭):
|
||||
5. 部署后创建第一个 admin。登录页会自动检测 `GET /api/auth/setup`;users 表为空时会显示创建 admin 表单。也可以用 API:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your.domain/api/auth/setup \
|
||||
|
||||
+16
-9
@@ -72,9 +72,7 @@ flowchart LR
|
||||
|
||||
进入 [Cloudflare 仪表盘](https://dash.cloudflare.com/) → `Workers & Pages` → `D1 SQL Database` → `Create`,名称填 `inbox-d1`。
|
||||
|
||||
点击进入数据库 → `Console`,粘贴并执行 [`db/schema.sql`](https://github.com/TooonyChen/AuthInbox/blob/main/db/schema.sql) 中的内容。
|
||||
|
||||
复制 `database_id`,下一步会用到。
|
||||
复制 `database_id`,下一步会用到。数据库表由仓库里的 `migrations/` 管理,部署 workflow 会先执行 D1 migrations。
|
||||
|
||||
2. **Fork 并部署**
|
||||
|
||||
@@ -83,11 +81,13 @@ flowchart LR
|
||||
在你 Fork 的仓库中,进入 `Settings` → `Secrets and variables` → `Actions`,添加以下 Secrets:
|
||||
- `CLOUDFLARE_ACCOUNT_ID`
|
||||
- `CLOUDFLARE_API_TOKEN`
|
||||
- `TOML` — 使用[不带注释的模板](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear),避免解析报错。
|
||||
- `TOML` — 使用[不带注释的模板](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear),填入 D1 `database_id` 和 AI 配置,避免解析报错。
|
||||
|
||||
然后进入 `Actions` → `Deploy Auth Inbox to Cloudflare Workers` → `Run workflow`。
|
||||
|
||||
部署成功后,**务必删除 workflow 日志**,防止配置信息泄露。
|
||||
部署成功后,到 Cloudflare Worker 的 `Settings` → `Variables and Secrets` 添加 secret:`JWT_SECRET`(一串足够长的随机字符串)。首次打开登录页时,系统会在 users 表为空时引导你创建第一个 admin。
|
||||
|
||||
**务必删除 workflow 日志**,防止配置信息泄露。
|
||||
|
||||
3. 跳转到[设置邮件转发](#3-设置邮件转发-)。
|
||||
|
||||
@@ -107,7 +107,6 @@ flowchart LR
|
||||
|
||||
```bash
|
||||
pnpm wrangler d1 create inbox-d1
|
||||
pnpm wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql
|
||||
```
|
||||
|
||||
复制输出中的 `database_id`。
|
||||
@@ -122,9 +121,7 @@ flowchart LR
|
||||
|
||||
```toml
|
||||
[vars]
|
||||
FrontEndAdminID = "你的登录账号"
|
||||
FrontEndAdminPassword = "你的登录密码"
|
||||
UseBark = "false"
|
||||
UseBark = "false"
|
||||
|
||||
# AI 提供商配置
|
||||
AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
@@ -138,6 +135,8 @@ flowchart LR
|
||||
database_id = "<你的数据库ID>"
|
||||
```
|
||||
|
||||
`FrontEndAdminID` / `FrontEndAdminPassword` 已不再使用。用户存储在 D1 的 `users` 表里;首次部署后通过登录页创建第一个 admin。
|
||||
|
||||
**`AI_API_FORMAT`** 三选一:
|
||||
|
||||
| 值 | 实际请求路径 | 适用提供商 |
|
||||
@@ -165,12 +164,20 @@ flowchart LR
|
||||
|
||||
可选 Bark 配置:`barkTokens`、`barkUrl`。
|
||||
|
||||
设置 JWT secret(生产环境不要写进 `wrangler.toml`):
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler secret put JWT_SECRET
|
||||
```
|
||||
|
||||
4. **构建并部署**
|
||||
|
||||
```bash
|
||||
pnpm run deploy
|
||||
```
|
||||
|
||||
`pnpm run deploy` 会依次构建前端、执行远端 D1 migrations、部署 Worker。
|
||||
|
||||
输出:`https://auth-inbox.<你的子域名>.workers.dev`
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"build:web": "pnpm -C web run build",
|
||||
"dev:web": "pnpm -C web run dev",
|
||||
"deploy": "pnpm run build:web && wrangler deploy",
|
||||
"deploy": "pnpm run build:web && pnpm run db:migrate:remote && wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Env } from "../types";
|
||||
import { extractMailInfo } from "../services/classify";
|
||||
import { isPromotionalEmail } from "../services/mime";
|
||||
import { decodeMimeHeader, isPromotionalEmail } from "../services/mime";
|
||||
import { RPCEmailMessage } from "./rpcEmail";
|
||||
|
||||
/*
|
||||
@@ -39,7 +39,7 @@ export async function handleEmail(message: ForwardableEmailMessage, env: Env): P
|
||||
? String((message as RPCEmailMessage).rawEmail)
|
||||
: await new Response(message.raw).text();
|
||||
const messageId = message.headers.get("Message-ID");
|
||||
const rawSubject = message.headers.get("Subject");
|
||||
const rawSubject = decodeMimeHeader(message.headers.get("Subject"));
|
||||
|
||||
// 边界 2: 进件必写 raw_mails
|
||||
const { success } = await env.DB.prepare(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Env, ProviderConfig, MailCategory } from "../types";
|
||||
import { MAIL_CATEGORIES } from "../types";
|
||||
import { extractMailBodies, stripHtmlTags } from "./mime";
|
||||
|
||||
/*
|
||||
* callProvider / extractJsonFromText 从旧 index.ts 原样迁移,
|
||||
@@ -111,8 +112,11 @@ async function callProvider(config: ProviderConfig, prompt: string): Promise<str
|
||||
}
|
||||
|
||||
function buildPrompt(rawEmail: string): string {
|
||||
const { textBody, htmlBody } = extractMailBodies(rawEmail);
|
||||
const emailContent = textBody ?? (htmlBody ? stripHtmlTags(htmlBody) : rawEmail);
|
||||
|
||||
return `
|
||||
Email content: ${rawEmail}
|
||||
Email content: ${emailContent}
|
||||
|
||||
Please read the email and extract the following information:
|
||||
1. Code/Link/Password from the email (if available).
|
||||
|
||||
+112
-27
@@ -1,12 +1,102 @@
|
||||
/*
|
||||
* 从旧 index.ts 原样迁移: quoted-printable 解码、HTML 去标签、
|
||||
* 推广邮件识别、MIME 正文提取。逻辑零改动。
|
||||
* MIME 正文/头部解码工具。邮件正文经常是 base64 或 quoted-printable
|
||||
* 包着 UTF-8/GBK 字节;不能把 atob()/=HH 的结果直接当 JS 字符串。
|
||||
*/
|
||||
|
||||
export function decodeQuotedPrintable(text: string): string {
|
||||
return text
|
||||
.replace(/=\r?\n/g, "")
|
||||
.replace(/=([A-Fa-f0-9]{2})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
||||
return decodeBytes(decodeQuotedPrintableBytes(text), "utf-8");
|
||||
}
|
||||
|
||||
function normaliseCharset(charset: string | null | undefined): string {
|
||||
const value = (charset ?? "utf-8").trim().replace(/^["']|["']$/g, "").toLowerCase();
|
||||
if (!value || value === "utf8" || value === "us-ascii") return "utf-8";
|
||||
if (value === "gb2312" || value === "gbk" || value === "gb_2312-80") return "gb18030";
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeBytes(bytes: Uint8Array, charset: string | null | undefined): string {
|
||||
try {
|
||||
return new TextDecoder(normaliseCharset(charset)).decode(bytes);
|
||||
} catch {
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
function binaryStringToBytes(binary: string): Uint8Array {
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i) & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeBase64Bytes(text: string): Uint8Array {
|
||||
return binaryStringToBytes(atob(text.replace(/\s/g, "")));
|
||||
}
|
||||
|
||||
function decodeQuotedPrintableBytes(text: string, headerMode = false): Uint8Array {
|
||||
const input = (headerMode ? text.replace(/_/g, " ") : text).replace(/=\r?\n/g, "");
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
if (input[i] === "=" && /^[A-Fa-f0-9]{2}$/.test(input.slice(i + 1, i + 3))) {
|
||||
bytes.push(Number.parseInt(input.slice(i + 1, i + 3), 16));
|
||||
i += 2;
|
||||
} else {
|
||||
bytes.push(input.charCodeAt(i) & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function unfoldHeaders(headers: string): string {
|
||||
return headers.replace(/\r?\n[ \t]+/g, " ");
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getHeader(headers: string, name: string): string | null {
|
||||
const match = unfoldHeaders(headers).match(new RegExp(`^${escapeRegExp(name)}:\\s*([^\\r\\n]*)`, "im"));
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function getCharset(contentType: string | null): string {
|
||||
return contentType?.match(/;\s*charset="?([^";\r\n]+)"?/i)?.[1] ?? "utf-8";
|
||||
}
|
||||
|
||||
function decodeBodyPart(body: string, encoding: string, charset: string): string {
|
||||
if (encoding === "base64") {
|
||||
try {
|
||||
return decodeBytes(decodeBase64Bytes(body), charset);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
if (encoding === "quoted-printable") {
|
||||
return decodeBytes(decodeQuotedPrintableBytes(body), charset);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export function decodeMimeHeader(value: string | null): string | null {
|
||||
if (!value) return value;
|
||||
|
||||
return value
|
||||
.replace(/(=\?[^?]+\?[bqBQ]\?[^?]*\?=)\s+(?==\?[^?]+\?[bqBQ]\?)/g, "$1")
|
||||
.replace(/=\?([^?]+)\?([bqBQ])\?([^?]*)\?=/g, (_match, charset, encoding, encoded) => {
|
||||
try {
|
||||
const bytes =
|
||||
encoding.toLowerCase() === "b"
|
||||
? decodeBase64Bytes(encoded)
|
||||
: decodeQuotedPrintableBytes(encoded, true);
|
||||
return decodeBytes(bytes, charset);
|
||||
} catch {
|
||||
return _match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function stripHtmlTags(text: string): string {
|
||||
@@ -47,11 +137,16 @@ export function extractMailBodies(rawEmail: string): {
|
||||
textBody: string | null;
|
||||
htmlBody: string | null;
|
||||
} {
|
||||
const boundaryMatch = rawEmail.match(/boundary="?([^"\r\n;]+)"?/i);
|
||||
const topLevel = rawEmail.match(/^([\s\S]*?)\r?\n\r?\n([\s\S]*)$/);
|
||||
const topHeaders = topLevel?.[1] ?? "";
|
||||
const topBody = topLevel?.[2] ?? rawEmail;
|
||||
const topContentType = getHeader(topHeaders, "Content-Type");
|
||||
const boundaryMatch = topContentType?.match(/boundary="?([^"\r\n;]+)"?/i);
|
||||
|
||||
if (boundaryMatch) {
|
||||
const boundary = boundaryMatch[1].trim();
|
||||
const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const parts = rawEmail.split(new RegExp(`--${escapedBoundary}(?:--)?\\r?\\n?`));
|
||||
const parts = topBody.split(new RegExp(`--${escapedBoundary}(?:--)?\\r?\\n?`));
|
||||
|
||||
let textBody: string | null = null;
|
||||
let htmlBody: string | null = null;
|
||||
@@ -67,21 +162,10 @@ export function extractMailBodies(rawEmail: string): {
|
||||
const body = headerBodyMatch[2].trim();
|
||||
if (!body) continue;
|
||||
|
||||
const contentType =
|
||||
headers.match(/Content-Type:\s*([^\r\n;]+)/i)?.[1]?.trim().toLowerCase() ?? "";
|
||||
const encoding =
|
||||
headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)?.[1]?.trim().toLowerCase() ?? "";
|
||||
|
||||
let decoded = body;
|
||||
if (encoding === "base64") {
|
||||
try {
|
||||
decoded = atob(body.replace(/\s/g, ""));
|
||||
} catch {
|
||||
decoded = body;
|
||||
}
|
||||
} else if (encoding === "quoted-printable") {
|
||||
decoded = decodeQuotedPrintable(body);
|
||||
}
|
||||
const contentTypeHeader = getHeader(headers, "Content-Type");
|
||||
const contentType = contentTypeHeader?.split(";")[0]?.trim().toLowerCase() ?? "";
|
||||
const encoding = getHeader(headers, "Content-Transfer-Encoding")?.toLowerCase() ?? "";
|
||||
const decoded = decodeBodyPart(body, encoding, getCharset(contentTypeHeader));
|
||||
|
||||
if (contentType.includes("text/html") && !htmlBody) {
|
||||
htmlBody = decoded.trim();
|
||||
@@ -96,12 +180,13 @@ export function extractMailBodies(rawEmail: string): {
|
||||
}
|
||||
}
|
||||
|
||||
const htmlMatch = rawEmail.match(/<html[\s\S]*<\/html>/i) ?? rawEmail.match(/<body[\s\S]*<\/body>/i);
|
||||
const htmlBody = htmlMatch ? decodeQuotedPrintable(htmlMatch[0]).trim() : null;
|
||||
const encoding = getHeader(topHeaders, "Content-Transfer-Encoding")?.toLowerCase() ?? "";
|
||||
const decodedBody = decodeBodyPart(topBody, encoding, getCharset(topContentType));
|
||||
const topMimeType = topContentType?.split(";")[0]?.trim().toLowerCase() ?? "";
|
||||
const htmlMatch = decodedBody.match(/<html[\s\S]*<\/html>/i) ?? decodedBody.match(/<body[\s\S]*<\/body>/i);
|
||||
const htmlBody = htmlMatch ? htmlMatch[0].trim() : null;
|
||||
|
||||
const splitParts = rawEmail.split(/\r?\n\r?\n/);
|
||||
const bodyText = splitParts.length > 1 ? splitParts.slice(1).join("\n\n") : rawEmail;
|
||||
const decodedText = decodeQuotedPrintable(bodyText).trim();
|
||||
const decodedText = topMimeType.includes("text/html") ? stripHtmlTags(decodedBody) : decodedBody.trim();
|
||||
const textBody = decodedText ? decodedText : htmlBody ? stripHtmlTags(htmlBody) : null;
|
||||
|
||||
return { textBody, htmlBody };
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeMimeHeader, decodeQuotedPrintable, extractMailBodies } from "../src/services/mime";
|
||||
|
||||
describe("mime decoding", () => {
|
||||
it("decodes UTF-8 base64 body parts as Unicode text", () => {
|
||||
const rawEmail = [
|
||||
'Content-Type: multipart/alternative; boundary="mail-boundary"',
|
||||
"",
|
||||
"--mail-boundary",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
"5Lit5paH6aqM6K+B56CB77yaMTIzNDU2",
|
||||
"--mail-boundary--",
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
expect(extractMailBodies(rawEmail).textBody).toBe("中文验证码:123456");
|
||||
});
|
||||
|
||||
it("decodes single-part base64 HTML emails", () => {
|
||||
const rawEmail = [
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
"PGh0bWw+PGJvZHk+PHA+5Lit5paH6aqM6K+B56CB77yaMTIzNDU2PC9wPjwvYm9keT48L2h0bWw+",
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
const bodies = extractMailBodies(rawEmail);
|
||||
expect(bodies.htmlBody).toContain("中文验证码:123456");
|
||||
expect(bodies.textBody).toBe("中文验证码:123456");
|
||||
});
|
||||
|
||||
it("decodes UTF-8 quoted-printable body parts as Unicode text", () => {
|
||||
expect(decodeQuotedPrintable("=E4=B8=AD=E6=96=87=E9=AA=8C=E8=AF=81=E7=A0=81=EF=BC=9A123456")).toBe(
|
||||
"中文验证码:123456",
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes RFC 2047 encoded subject headers", () => {
|
||||
expect(decodeMimeHeader("=?UTF-8?B?6aqM6K+B56CB?=")).toBe("验证码");
|
||||
});
|
||||
});
|
||||
@@ -46,17 +46,6 @@ barkTokens = "[token1, token2]"
|
||||
# 设定你的 bark tokens,从 https://bark.day.app/ 下载iOS APP,你可以使用多个 tokens,如果你只使用一个 token,那么设置为 "[token1]"
|
||||
# 如果你不使用 bark 推送,那么无需改动
|
||||
|
||||
FrontEndAdminID = "admin"
|
||||
# FrontEndAdminID: String
|
||||
# your login **Please change it to your own login**
|
||||
# 你的登录账号 **请更改为你自己的登录账号**
|
||||
|
||||
FrontEndAdminPassword = "password"
|
||||
# FrontEndAdminPassword: String
|
||||
# your password **Please change it to your own password**
|
||||
# 你的登录密码 **请更改为你自己的登录密码**
|
||||
|
||||
|
||||
# --- Primary AI provider ---
|
||||
AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
# Base URL of your AI provider (no trailing slash).
|
||||
|
||||
@@ -14,8 +14,6 @@ enabled = true
|
||||
UseBark = "true"
|
||||
barkUrl = "https://api.day.app"
|
||||
barkTokens = "[token1, token2]"
|
||||
FrontEndAdminID = "admin"
|
||||
FrontEndAdminPassword = "password"
|
||||
AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
AI_API_KEY = "your-api-key"
|
||||
AI_API_FORMAT = "openai"
|
||||
|
||||
Reference in new issue
Block a user