chore: 并入已删除会话的在途成果(防丢失;原会话已删,未做功能验收)
**背景**:这些改动原属本工作区另外几个会话(T01/T02 等),**那些会话已被用户删除** ⇒
工作树里的成果处于"无主"状态,一次错误 checkout / 覆盖即**永久丢失** ⇒ 代入库保全。
口径遵循本项目**先例**(`39a1f2e` / `b617cdb`:**别人的活,代入库并在提交信息里注明**)。
**内容**:档案 101「能力管理」改名 + 页内 tab 分页|档案 102 语言切换搬入「用户设置」|
`07-实例UI分区登记表.md`|`scripts/find-ui*.mjs`(UI 元素定位工具)|`poc/portal-entry/`(0.5.3)|
`src/web/locale-pref.ts` + `home-files.ts`(语言偏好持久化)|`test/locale-pref.test.mjs`|
`package.json`|`BRIEF.md` / `INDEX.md` / `docs-manifest.json` / `03-路线图与待办.md` / 档案 100 增量。
**已做最小健全性检查**(⚠️ **未跑完整构建 / 单测** —— 那是原会话的验收职责,本次只求"不丢"):
- JSON 合法:`package.json` / `poc/business-plugins/package.json` / `docs-manifest.json` ✓
- 4 个 TS 文件 `{}`/`()` 配平 ✓;新增文件均非空 ✓
- 规模:13 文件改动 +340/−210,新增 12 条
**未 push**(按 §4 提交边界:用户说"提交",未说"推送")。
This commit is contained in:
1 parent
03c8363960
commit
3efd68517f
27 files changed
+1712
-206
No files matched your search
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 用户 home 下配置文件的读写(`settings.yaml` / `.credentials.yaml`)。
|
||||
*
|
||||
* **为什么单独成模块**:这段逻辑原先**内联在 `server.ts` 的闭包里**,只有"模型落地"那一条路径能用;
|
||||
* 2026-09-15 加"语言偏好持久化"(`/api/me/locale`)时需要**同一套**语义 —— 与其复制一份
|
||||
* (两份实现迟早漂),不如抽出来共用(R11:同一事实只有一处)。
|
||||
*
|
||||
* ⚠️ **`writeHomeFile` 里那两步都不能省**(都是从事故里换来的):
|
||||
* ① **先备份到平台目录**(`DSH_PLATFORM_BACKUP_DIR`,默认 `/opt/dsh/backups`)
|
||||
* —— ⛔ 不能备份进用户 home:那是 dsh 的 watch 域,放进去的文件会被扫;
|
||||
* ② **写完 chown 给 home 属主** —— 实例以 `dsh-<uid>` 身份运行,root 写的 0600 文件它**读不了**
|
||||
* ⇒ 漏掉这步就是"配置写了但实例死活读不到"(档案 43 / R10 同族)。
|
||||
*
|
||||
* @module dshs/web/home-files
|
||||
*/
|
||||
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { chown, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
|
||||
/** 读文本,文件不存在 / 读不动 ⇒ 空串(调用方按"从零建文档"处理)。 */
|
||||
export async function readTextOrEmpty(file: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(file, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写 home 里的配置文件:备份(落**平台目录**)→ 写 → chown 给 home 属主。
|
||||
* 实例以 dsh-<uid> 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。
|
||||
*/
|
||||
export async function writeHomeFile(homeDir: string, file: string, text: string): Promise<void> {
|
||||
try {
|
||||
const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups'
|
||||
await mkdir(bakDir, { recursive: true })
|
||||
const label = basename(file).replace(/^\./, '').replace(/\.ya?ml$/, '')
|
||||
// ⚠️ 带上 home 的**父目录名**(= 用户 id):只写 basename 的话每个人都是 "home",
|
||||
// 备份文件互相看不出是谁的(旧实现就是这个毛病:credentials-home-*.yaml)。
|
||||
const who = basename(dirname(homeDir))
|
||||
writeFileSync(join(bakDir, `${label}-${who}-${Date.now()}.yaml`), text, { mode: 0o600 })
|
||||
} catch {
|
||||
/* 备份失败不阻断 */
|
||||
}
|
||||
await writeFile(file, text, { mode: 0o600 })
|
||||
try {
|
||||
const st = await stat(homeDir)
|
||||
await chown(file, st.uid, st.gid)
|
||||
} catch {
|
||||
/* chown 失败(非 root 运行等)不阻断 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 语言偏好持久化 —— 把用户在实例里选的界面语言**记住**。
|
||||
*
|
||||
* ## 为什么需要它(2026-09-15)
|
||||
* 官方 `dsh-client-locale` README 原文:语言选择立即生效,但**只有 loopback 页面**会把选择
|
||||
* 持久化到 `$DSH_HOME/settings.yaml`;**非 loopback 页面只为当前进程保留**。
|
||||
* 平台是"浏览器经域名访问远程实例" ⇒ **属非 loopback** ⇒ 用户切完语言、一刷新就回默认英语。
|
||||
*
|
||||
* ## 最优方案 = **A(守官方语义)+ B(记住选择)同时成立**
|
||||
* 不是"另造一套语言状态",而是**替官方把它的设置写进它自己的文件**:
|
||||
* `settings.yaml` 顶层 `locale:` → `preference: <id>`(键名取自官方 locale 包的 settings schema,
|
||||
* 已核对:该包 host 半边的 schema 用的就是 `locale` / `preference`)。
|
||||
* ⇒ 官方运行时启动时读自己的设置文件即生效(A 成立),用户的选择跨页面 / 跨重启保留(B 成立)。
|
||||
* ⇒ **零官方改动**、不新增平台侧语言状态(单一来源仍是官方 settings.yaml)。
|
||||
*
|
||||
* 与 `model-landing.ts` 的关系:同一个文件、同一套"按行对账"的写法(不整份 YAML 解析,
|
||||
* 避免把注释 / 顺序 / 未知字段写坏),并且**只动自己那两行**。
|
||||
*
|
||||
* @module dshs/web/locale-pref
|
||||
*/
|
||||
|
||||
/** 官方 `dsh-client-locale` 的 `LOCALE_IDS`(`en` 是它的 FALLBACK,即默认英语)。 */
|
||||
export const LOCALE_IDS = ['en', 'zh'] as const
|
||||
export type LocaleId = (typeof LOCALE_IDS)[number]
|
||||
|
||||
export function isLocaleId(value: unknown): value is LocaleId {
|
||||
return typeof value === 'string' && (LOCALE_IDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** 顶层块名与缩进(官方 schema:顶层 `locale:`,其下 2 空格 `preference:`)。 */
|
||||
const BLOCK = 'locale'
|
||||
const INDENT = ' '
|
||||
const KEY = 'preference'
|
||||
|
||||
/**
|
||||
* 把 `preference: <id>` 对账进 `settings.yaml` 文本。
|
||||
*
|
||||
* 行为(全部按"只碰自己那两行"设计):
|
||||
* · 已有顶层 `locale:` 块 + 其下 `preference:` ⇒ **原地替换值**;
|
||||
* · 已有顶层 `locale:` 块但**没有** `preference:` ⇒ 紧跟块头插入一行;
|
||||
* · 没有 `locale:` 块 ⇒ 追加 `locale:\n preference: <id>\n`;
|
||||
* · 值已等于目标 ⇒ `changed: false`(幂等,调用方可据此跳过写盘)。
|
||||
*
|
||||
* ⛔ **不整份解析 YAML**:这一文件里还有别的插件写的块与用户注释,解析再回写会把它们写坏
|
||||
* (`model-landing.ts` 头注释里记着同类教训)。
|
||||
*/
|
||||
export function reconcileLocalePreference(text: string, preference: LocaleId): { text: string; changed: boolean } {
|
||||
const eol = text.includes('\r\n') ? '\r\n' : '\n'
|
||||
const lines = text === '' ? [] : text.split(/\r?\n/)
|
||||
const want = `${INDENT}${KEY}: ${preference}`
|
||||
|
||||
const isBlockHead = (l: string): boolean => new RegExp(`^${BLOCK}\\s*:\\s*(#.*)?$`).test(l)
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!isBlockHead(lines[i]!)) continue
|
||||
// 块内找 preference(缩进 ≥ 2 且不是更深层嵌套)
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const l = lines[j]!
|
||||
if (l.trim() === '' || l.trimStart().startsWith('#')) continue
|
||||
// 出了本块(缩进 0 的新键 / 更深层缩进的子块)⇒ 停
|
||||
if (!/^\s/.test(l)) break
|
||||
const m = new RegExp(`^(\\s+)${KEY}\\s*:\\s*(.*)$`).exec(l)
|
||||
if (m) {
|
||||
const cur = m[2]!.trim().replace(/^["']|["']$/g, '')
|
||||
if (cur === preference) return { text, changed: false }
|
||||
lines[j] = `${INDENT}${KEY}: ${preference}`
|
||||
return { text: lines.join(eol), changed: true }
|
||||
}
|
||||
}
|
||||
// 块头存在但没有 preference ⇒ 紧跟其后插入
|
||||
lines.splice(i + 1, 0, want)
|
||||
return { text: lines.join(eol), changed: true }
|
||||
}
|
||||
|
||||
// 没有 locale 块 ⇒ 追加(去掉尾部空行再加,保持文档整洁)
|
||||
while (lines.length > 0 && lines[lines.length - 1]!.trim() === '') lines.pop()
|
||||
lines.push(`${BLOCK}:`, want)
|
||||
return { text: lines.join(eol) + eol, changed: true }
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import { deriveKey, encrypt } from '../../crypto.js'
|
||||
import { toPublicUser } from '../../db/types.js'
|
||||
import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js'
|
||||
import { PROTOCOLS } from '../model-landing.js'
|
||||
import { readTextOrEmpty, writeHomeFile } from '../home-files.js'
|
||||
import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
clearSessionCookie,
|
||||
hashPassword,
|
||||
@@ -22,6 +25,15 @@ import {
|
||||
verifyPassword,
|
||||
} from '../auth.js'
|
||||
|
||||
const localeSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['locale'],
|
||||
additionalProperties: false,
|
||||
properties: { locale: { type: 'string', minLength: 2, maxLength: 8 } },
|
||||
},
|
||||
} as const
|
||||
|
||||
const registerSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
@@ -345,6 +357,26 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
* @deprecated 档案 87 起语义已变成"启用这一个、**不关**别的"(与 `toggle(true)` 同义)。
|
||||
* 保留路由只为老客户端不 404;新前端不该再用它。
|
||||
*/
|
||||
/**
|
||||
* 语言偏好持久化(2026-09-15,档案 102)—— 把用户在实例「用户设置」里选的语言**记住**。
|
||||
*
|
||||
* 为什么需要:官方 `dsh-client-locale` 只对 **loopback** 页面持久化到 `settings.yaml`;平台是
|
||||
* 「浏览器经域名访问远程实例」⇒ 非 loopback ⇒ 官方只为当前进程保留选择,刷新即回默认。
|
||||
* 本路由**替官方把它自己的设置写进它自己的文件**(顶层 `locale: → preference:`),
|
||||
* ⇒ 官方语义不破(实例启动时读自己的设置即生效)+ 用户选择跨页面 / 跨重启保留。
|
||||
* ⚠️ 写文件沿用 `model-landing` 那套(备份到平台目录 + chown 给 home 属主),见 `home-files.ts`。
|
||||
*/
|
||||
app.post('/api/me/locale', { preHandler: requireAuth, schema: localeSchema }, async (request, reply) => {
|
||||
const { locale } = request.body as { locale: string }
|
||||
if (!isLocaleId(locale)) return reply.code(400).send({ error: 'invalid_locale' })
|
||||
const homeDir = homeRoot(userRoot(app.config.dataRoot, request.user!.id))
|
||||
const file = join(homeDir, 'settings.yaml')
|
||||
const text = await readTextOrEmpty(file)
|
||||
const next = reconcileLocalePreference(text, locale)
|
||||
if (next.changed) await writeHomeFile(homeDir, file, next.text)
|
||||
return { ok: true, locale, changed: next.changed }
|
||||
})
|
||||
|
||||
app.post('/api/me/keys/:id/select', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
if (!(await app.db.selectCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
|
||||
|
||||
+3
-31
@@ -32,6 +32,7 @@ import {
|
||||
refForEntry,
|
||||
type SettingsEntry,
|
||||
} from './model-landing.js'
|
||||
import { readTextOrEmpty, writeHomeFile } from './home-files.js'
|
||||
import { rateLimit } from './middleware/rate-limit.js'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { adminRoutes } from './routes/admin.js'
|
||||
@@ -120,37 +121,8 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
await mkdir(managedDir, { recursive: true })
|
||||
await writeFile(join(managedDir, userId + '.json'), JSON.stringify(m), { mode: 0o600 })
|
||||
}
|
||||
const readTextOrEmpty = async (file: string): Promise<string> => {
|
||||
try {
|
||||
return await readFile(file, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 写 home 里的配置文件:备份(落**平台目录**)→ 写 → chown 给 home 属主。
|
||||
* 实例以 dsh-<uid> 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。
|
||||
*/
|
||||
const writeHomeFile = async (homeDir: string, file: string, text: string): Promise<void> => {
|
||||
try {
|
||||
const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups'
|
||||
await mkdir(bakDir, { recursive: true })
|
||||
const label = basename(file).replace(/^\./, '').replace(/\.ya?ml$/, '')
|
||||
// ⚠️ 带上 home 的**父目录名**(= 用户 id):只写 basename 的话每个人都是 "home",
|
||||
// 备份文件互相看不出是谁的(旧实现就是这个毛病:credentials-home-*.yaml)。
|
||||
const who = basename(dirname(homeDir))
|
||||
writeFileSync(join(bakDir, `${label}-${who}-${Date.now()}.yaml`), text, { mode: 0o600 })
|
||||
} catch {
|
||||
/* 备份失败不阻断 */
|
||||
}
|
||||
await writeFile(file, text, { mode: 0o600 })
|
||||
try {
|
||||
const st = await stat(homeDir)
|
||||
await chown(file, st.uid, st.gid)
|
||||
} catch {
|
||||
/* chown 失败(非 root 运行等)不阻断 */
|
||||
}
|
||||
}
|
||||
// `readTextOrEmpty` / `writeHomeFile` 已抽到 `./home-files.js`(2026-09-15:语言偏好
|
||||
// 持久化也要用同一套「写 home 文件」语义 —— 与其复制一份,不如共用;约束见该模块头注释)。
|
||||
|
||||
/** 平台共享条目(admin 配的、已启用的那些)—— 只在该用户开关打开、且他不是那个 admin 时纳入。 */
|
||||
const sharedLandingRows = async (userId: string): Promise<CredentialLandingRow[]> => {
|
||||
|
||||
Reference in new issue
Block a user