Files
dsh_shenxian/src/db/sqlite.ts
T
admin 971ccc3703 feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

【档案 134 · 注册页人机验证 + 邮箱验证码】
- DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引)
- 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts
- routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code;
  注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为)
- 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF)
- 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯
  (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定
- Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的,
  只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的
- 新增 test/register-guard.test.mjs(19 用例)

【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】
- login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形
  → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name)
- portal 顶栏换图标(页面名「管理门户」保留)
- 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它
- 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果)
- scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG
  解析失败、图标静默不显示 —— 实际踩到过)
- 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束)

【档案 135/136 · 域名迁移线(另一会话产出)】
- 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置
- src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新;
  src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs
- 档案 136 = 控制面按两台中继取并集(**已立项、未落地**)

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
2026-09-19 09:11:24 +08:00

440 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SQLite backend for {@link DbAdapter}. Wraps the synchronous better-sqlite3
* repo in async methods and maps constraint errors onto the shared hierarchy.
* Used when `deployMode=local` (no `DSHS_DB_URL`).
*
* NOTE: better-sqlite3 is synchronous; the `async` here only matches the
* interface — the underlying calls still block the event loop. Fine for the
* small single-machine local mode this backend targets (see docs/k8s.md §5.1).
* @module dshs/db/sqlite
*/
import type { DbAdapter } from './adapter.js'
import { openDatabase, type Database } from './connection.js'
import { mapSqliteError } from './errors.js'
import {
audit as auditSync,
bumpEmailCodeAttempts as bumpEmailCodeAttemptsSync,
consumeEmailCode as consumeEmailCodeSync,
countAdmins as countAdminsSync,
createSession as createSessionSync,
createUser as createUserSync,
deleteBusinessPlugin as deleteBusinessPluginSync,
deleteCredentialKey as deleteCredentialKeySync,
deleteInstance as deleteInstanceSync,
deleteSession as deleteSessionSync,
deleteUser as deleteUserSync,
deleteUserInstances as deleteUserInstancesSync,
deleteUserSessions as deleteUserSessionsSync,
emailCodeCounts as emailCodeCountsSync,
findBusinessPlugin as findBusinessPluginSync,
findDomainById as findDomainByIdSync,
findDomainByUser as findDomainByUserSync,
findInstance as findInstanceSync,
findSession as findSessionSync,
findSessionWithUser as findSessionWithUserSync,
hasActiveSession as hasActiveSessionSync,
findUserByEmail as findUserByEmailSync,
findUserById as findUserByIdSync,
findUserBySlug as findUserBySlugSync,
findUserByUsername as findUserByUsernameSync,
findUserInstance as findUserInstanceSync,
findWorkspaceByPath as findWorkspaceByPathSync,
getEnabledCredentialKeyRef as getEnabledCredentialKeyRefSync,
getEnabledPluginIds as getEnabledPluginIdsSync,
getOrCreateWorkspace as getOrCreateWorkspaceSync,
getSharedModelEnabled as getSharedModelEnabledSync,
latestSentEmailCode as latestSentEmailCodeSync,
listBusinessPlugins as listBusinessPluginsSync,
listCredentialKeys as listCredentialKeysSync,
listCredentialLandingRows as listCredentialLandingRowsSync,
listDomains as listDomainsSync,
listEnabledCredentialKeys as listEnabledCredentialKeysSync,
listInstancesByRole as listInstancesByRoleSync,
listPublicUsers as listPublicUsersSync,
listUsersWithoutUid as listUsersWithoutUidSync,
purgeEmailCodes as purgeEmailCodesSync,
recordEmailCode as recordEmailCodeSync,
selectCredentialKey as selectCredentialKeySync,
setCredentialKey as setCredentialKeySync,
setDomainVerified as setDomainVerifiedSync,
setFolderPlugins as setFolderPluginsSync,
setInstanceStatus as setInstanceStatusSync,
setSharedModelEnabled as setSharedModelEnabledSync,
setUserRole as setUserRoleSync,
setUserUid as setUserUidSync,
toggleCredentialKey as toggleCredentialKeySync,
// 集群化(v7;T08 S2)
claimInstance as claimInstanceSync,
findDshHost as findDshHostSync,
listDshHosts as listDshHostsSync,
listExpiredInstanceLeases as listExpiredInstanceLeasesSync,
listInstancesByHost as listInstancesByHostSync,
pinInstanceHost as pinInstanceHostSync,
releaseInstanceLease as releaseInstanceLeaseSync,
renewInstanceLease as renewInstanceLeaseSync,
setDshHostStatus as setDshHostStatusSync,
upsertDshHost as upsertDshHostSync,
upsertBusinessPlugin as upsertBusinessPluginSync,
upsertDomain as upsertDomainSync,
upsertInstance as upsertInstanceSync,
} from './repo.js'
import type {
BusinessPlugin,
ClaimResult,
CredentialKey,
CredentialKeyMeta,
CredentialLandingRow,
CreateSessionInput,
CreateUserInput,
Domain,
DshHost,
DshHostStatus,
DshInstance,
DshInstanceRole,
DshInstanceStatus,
EmailCodeCounts,
EmailCodeRow,
PublicUser,
RecordEmailCodeInput,
SessionRow,
SessionUser,
UpsertBusinessPluginInput,
UpsertDshHostInput,
UpsertDshInstanceInput,
User,
UserRole,
Workspace,
} from './types.js'
export class SqliteAdapter implements DbAdapter {
private readonly db: Database
constructor(path: string, private readonly baseUid: number) {
this.db = openDatabase(path)
}
async createUser(input: CreateUserInput): Promise<User> {
try {
return createUserSync(this.db, input, this.baseUid)
} catch (e) {
mapSqliteError(e)
}
}
async findUserByUsername(username: string): Promise<User | undefined> {
return findUserByUsernameSync(this.db, username)
}
async findUserBySlug(slug: string): Promise<User | undefined> {
return findUserBySlugSync(this.db, slug)
}
async findUserById(id: string): Promise<User | undefined> {
return findUserByIdSync(this.db, id)
}
async listPublicUsers(): Promise<PublicUser[]> {
return listPublicUsersSync(this.db)
}
async countAdmins(): Promise<number> {
return countAdminsSync(this.db)
}
async setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean> {
return setUserRoleSync(this.db, id, role, approvedBy)
}
async setUserUid(userId: string, uid: number): Promise<void> {
setUserUidSync(this.db, userId, uid)
}
async listUsersWithoutUid(): Promise<string[]> {
return listUsersWithoutUidSync(this.db)
}
async createSession(input: CreateSessionInput): Promise<void> {
try {
createSessionSync(this.db, input)
} catch (e) {
mapSqliteError(e)
}
}
async findSession(tokenHash: string): Promise<SessionRow | undefined> {
return findSessionSync(this.db, tokenHash)
}
async deleteSession(tokenHash: string): Promise<void> {
deleteSessionSync(this.db, tokenHash)
}
async deleteUserSessions(userId: string): Promise<void> {
deleteUserSessionsSync(this.db, userId)
}
async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> {
return findSessionWithUserSync(this.db, tokenHash)
}
async hasActiveSession(userId: string): Promise<boolean> {
return hasActiveSessionSync(this.db, userId)
}
async audit(actor: string | null, action: string, detail?: string | null): Promise<void> {
auditSync(this.db, actor, action, detail)
}
// ── v10 邮箱验证码(注册页)────────────────────────────────────────────────
async findUserByEmail(email: string): Promise<User | undefined> {
return findUserByEmailSync(this.db, email)
}
async recordEmailCode(input: RecordEmailCodeInput): Promise<void> {
recordEmailCodeSync(this.db, input)
}
async emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts> {
return emailCodeCountsSync(this.db, email, ip, since)
}
async latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined> {
return latestSentEmailCodeSync(this.db, email, purpose)
}
async bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean> {
return bumpEmailCodeAttemptsSync(this.db, id, consume, now)
}
async consumeEmailCode(id: string, now: number): Promise<boolean> {
return consumeEmailCodeSync(this.db, id, now)
}
async purgeEmailCodes(before: number): Promise<number> {
return purgeEmailCodesSync(this.db, before)
}
async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> {
return findWorkspaceByPathSync(this.db, userId, relPath)
}
async getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace> {
try {
return getOrCreateWorkspaceSync(this.db, userId, relPath)
} catch (e) {
mapSqliteError(e)
}
}
async setFolderPlugins(
workspaceId: string,
selections: ReadonlyArray<{ id: string; enabled: boolean }>,
): Promise<void> {
try {
setFolderPluginsSync(this.db, workspaceId, selections)
} catch (e) {
mapSqliteError(e)
}
}
async getEnabledPluginIds(workspaceId: string): Promise<string[]> {
return getEnabledPluginIdsSync(this.db, workspaceId)
}
async listBusinessPlugins(): Promise<BusinessPlugin[]> {
return listBusinessPluginsSync(this.db)
}
async findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined> {
return findBusinessPluginSync(this.db, id)
}
async upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin> {
try {
return upsertBusinessPluginSync(this.db, input)
} catch (e) {
mapSqliteError(e)
}
}
async deleteBusinessPlugin(id: string): Promise<boolean> {
return deleteBusinessPluginSync(this.db, id)
}
async findDomainByUser(userId: string): Promise<Domain | undefined> {
return findDomainByUserSync(this.db, userId)
}
async findDomainById(id: string): Promise<Domain | undefined> {
return findDomainByIdSync(this.db, id)
}
async listDomains(): Promise<Domain[]> {
return listDomainsSync(this.db)
}
async upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain> {
try {
return upsertDomainSync(this.db, userId, domain, nginxConfig)
} catch (e) {
mapSqliteError(e)
}
}
async setDomainVerified(id: string, verified: boolean): Promise<boolean> {
return setDomainVerifiedSync(this.db, id, verified)
}
async listCredentialKeys(userId: string): Promise<CredentialKey[]> {
return listCredentialKeysSync(this.db, userId)
}
async listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]> {
return listEnabledCredentialKeysSync(this.db, userId)
}
async listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]> {
return listCredentialLandingRowsSync(this.db, userId)
}
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
return getEnabledCredentialKeyRefSync(this.db, userId)
}
async setCredentialKey(
userId: string,
name: string,
encryptedRef: string,
meta?: CredentialKeyMeta,
): Promise<CredentialKey> {
try {
return setCredentialKeySync(this.db, userId, name, encryptedRef, meta)
} catch (e) {
mapSqliteError(e)
}
}
async selectCredentialKey(userId: string, id: string): Promise<boolean> {
return selectCredentialKeySync(this.db, userId, id)
}
async toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean> {
return toggleCredentialKeySync(this.db, userId, id, enabled)
}
async getSharedModelEnabled(userId: string): Promise<boolean> {
return getSharedModelEnabledSync(this.db, userId)
}
async setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean> {
return setSharedModelEnabledSync(this.db, userId, enabled)
}
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
return deleteCredentialKeySync(this.db, userId, id)
}
async deleteUser(userId: string): Promise<boolean> {
return deleteUserSync(this.db, userId)
}
async upsertInstance(input: UpsertDshInstanceInput): Promise<void> {
try {
upsertInstanceSync(this.db, input)
} catch (e) {
mapSqliteError(e)
}
}
async findInstance(id: string): Promise<DshInstance | undefined> {
return findInstanceSync(this.db, id)
}
async findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined> {
return findUserInstanceSync(this.db, userId, role)
}
async listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]> {
return listInstancesByRoleSync(this.db, role)
}
async setInstanceStatus(
id: string,
status: DshInstanceStatus,
outcome?: { exitCode?: number; lastError?: string },
): Promise<boolean> {
return setInstanceStatusSync(this.db, id, status, outcome)
}
async deleteInstance(id: string): Promise<boolean> {
return deleteInstanceSync(this.db, id)
}
async deleteUserInstances(userId: string): Promise<void> {
deleteUserInstancesSync(this.db, userId)
}
// ── 集群化:worker 注册表 + 归属/租约(v7;T08 S2)────────────────────────
// local 模式不会走到这些方法(`LocalSpawner` 不写库),它们只是让
// **SQLite 侧与 PG 侧行为一致** —— 测试与单机试跑都需要。
async upsertDshHost(input: UpsertDshHostInput): Promise<DshHost> {
try {
return upsertDshHostSync(this.db, input)
} catch (e) {
mapSqliteError(e)
}
}
async findDshHost(id: string): Promise<DshHost | undefined> {
return findDshHostSync(this.db, id)
}
async listDshHosts(): Promise<DshHost[]> {
return listDshHostsSync(this.db)
}
async setDshHostStatus(
id: string,
status: DshHostStatus,
usedMb?: number,
heartbeatAt?: number,
): Promise<boolean> {
return setDshHostStatusSync(this.db, id, status, usedMb, heartbeatAt)
}
async claimInstance(
userId: string,
hostId: string,
ttlMs: number,
meta?: { folder?: string; patch?: string },
): Promise<ClaimResult> {
return claimInstanceSync(this.db, userId, hostId, ttlMs, meta)
}
async renewInstanceLease(userId: string, hostId: string, epoch: number, ttlMs: number): Promise<boolean> {
return renewInstanceLeaseSync(this.db, userId, hostId, epoch, ttlMs)
}
async releaseInstanceLease(userId: string, hostId: string, epoch: number): Promise<boolean> {
return releaseInstanceLeaseSync(this.db, userId, hostId, epoch)
}
async pinInstanceHost(userId: string, hostId: string): Promise<void> {
pinInstanceHostSync(this.db, userId, hostId)
}
async listExpiredInstanceLeases(now: number): Promise<DshInstance[]> {
return listExpiredInstanceLeasesSync(this.db, now)
}
async listInstancesByHost(hostId: string): Promise<DshInstance[]> {
return listInstancesByHostSync(this.db, hostId)
}
async close(): Promise<void> {
this.db.close()
}
}