feat: add API config system, FilePipe backend, and ConfigManager

- ConfigManager: route sensitive keys (API keys) to localStorage,
  migrate existing values out of extension_settings on startup
- ApiKeyStore: local/cloud storage modes with RSA+AES hybrid encryption
- ApiProfileManager: named connection profiles (chat/embedding/rerank)
  with per-slot type-validated assignments
- FilePipe: complete IndexedDB backend (read/write/delete/list/stat)
- Amily2Bus: inject FilePipe via forPlugin() capability token
- UI: api-config-panel with profile CRUD and slot assignment
- TableSystemService: initial service layer scaffold
- logger.js: XSS fix
This commit is contained in:
2026-03-10 22:07:15 +08:00
parent ed3f52a568
commit 0be6a86e94
17 changed files with 1970 additions and 110 deletions

View File

@@ -1,61 +1,260 @@
/**
* FilePipe — 插件独立文件存储管道
*
* 解决的问题:
* SillyTavern 的 settings.json 被所有插件共享大型内容prompt 模板、摘要、
* 优化结果、缓存)写入后导致文件膨胀,且功能迭代残留的废弃 key 永久堆积。
*
* 方案:
* 以 IndexedDB 为后端,每个插件在独立命名空间下进行读写。
* 与 settings.json 完全隔离,不参与云同步,无体积上限约束。
*
* 存储结构:
* DB : 'Amily2_FilePipe'
* Store: 'files'
* Key : 复合键 [plugin, path](无需为新插件升级 DB 版本)
* Entry: { plugin, path, data, updatedAt }
*
* 安全:
* - 路径禁止包含 '..'(防目录穿越)
* - 每个插件只能读写自己命名空间下的路径
*
* 使用方式(通过 Amily2Bus capability token
* const file = ctx.file; // Amily2Bus 注入
* await file.write('config.json', { key: 'value' });
* const data = await file.read('config.json');
* await file.delete('config.json');
* const list = await file.list();
*/
const DB_NAME = 'Amily2_FilePipe';
const DB_VERSION = 1;
const STORE_NAME = 'files';
// ── IndexedDB 工具 ────────────────────────────────────────────────────────────
let _dbPromise = null;
function _openDB() {
if (_dbPromise) return _dbPromise;
_dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: ['plugin', 'path'],
});
// 按插件名索引,方便 list() 查询
store.createIndex('by_plugin', 'plugin', { unique: false });
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = (e) => {
_dbPromise = null;
reject(new Error(`[FilePipe] IndexedDB 打开失败: ${e.target.error}`));
};
});
return _dbPromise;
}
function _tx(db, mode) {
return db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
}
function _idbRequest(req) {
return new Promise((resolve, reject) => {
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = (e) => reject(e.target.error);
});
}
// ── FilePipe ──────────────────────────────────────────────────────────────────
class FilePipe {
constructor() {
this.name = "FilePipe";
// 模拟的根存储路径,实际环境可能对应 IndexedDB 的 Store Name 或 Node 的 baseDir
this.basePath = "/virtual_fs/";
this.name = 'FilePipe';
}
/**
* 安全路径解析与校验
* @param {string} plugin 插件名称(命名空间)
* @param {string} relativePath 相对路径
* @returns {string|null} 合法的绝对路径,如果违规则返回 null
*/
_resolvePath(plugin, relativePath) {
// ── 安全路径校验 ─────────────────────────────────────────────────────────
_safePath(plugin, path) {
if (!plugin || typeof plugin !== 'string') {
console.error(`[FilePipe] Security Error: Invalid plugin identity.`);
console.error('[FilePipe] 无效的插件标识。');
return null;
}
// 简单防越权:禁止包含 ".."
if (relativePath.includes('..')) {
console.error(`[FilePipe] Security Error: Directory traversal attempt blocked for plugin '${plugin}'. Path: ${relativePath}`);
if (!path || typeof path !== 'string') {
console.error('[FilePipe] 无效的路径。');
return null;
}
// 强制限定在插件目录下
// 格式: /virtual_fs/PluginName/filename
return `${this.basePath}${plugin}/${relativePath}`;
if (path.includes('..')) {
console.error(`[FilePipe] 安全拦截:插件 "${plugin}" 尝试目录穿越,路径: ${path}`);
return null;
}
// 规范化:去掉开头的斜杠
return path.replace(/^\/+/, '');
}
// ── 公开 API ─────────────────────────────────────────────────────────────
/**
* 读取文件
* @param {string} plugin 调用方插件名
* @param {string} path 文件相对路径
* 读取文件
* @param {string} plugin 插件名(命名空间)
* @param {string} path 文件路径(相对于插件根目录)
* @returns {Promise<any>} 存储的数据,不存在时返回 null
*/
async read(plugin, path) {
const safePath = this._resolvePath(plugin, path);
const safePath = this._safePath(plugin, path);
if (!safePath) return null;
console.log(`[FilePipe] Reading from: ${safePath}`);
// TODO: Implement actual file reading logic
return null;
try {
const db = await _openDB();
const result = await _idbRequest(_tx(db, 'readonly').get([plugin, safePath]));
return result?.data ?? null;
} catch (e) {
console.error(`[FilePipe] read 失败 (${plugin}/${path}):`, e);
return null;
}
}
/**
* 写入文件
* @param {string} plugin 调用方插件名
* @param {string} path 文件相对路径
* @param {any} data 数据
* 写入文件
* @param {string} plugin 插件名
* @param {string} path 文件路径
* @param {any} data 任意可序列化数据对象、字符串、ArrayBuffer 等)
* @returns {Promise<boolean>}
*/
async write(plugin, path, data) {
const safePath = this._resolvePath(plugin, path);
const safePath = this._safePath(plugin, path);
if (!safePath) return false;
console.log(`[FilePipe] Writing to: ${safePath}`);
// TODO: Implement actual file writing logic
return true;
try {
const db = await _openDB();
await _idbRequest(_tx(db, 'readwrite').put({
plugin,
path: safePath,
data,
updatedAt: new Date().toISOString(),
}));
return true;
} catch (e) {
console.error(`[FilePipe] write 失败 (${plugin}/${path}):`, e);
return false;
}
}
/**
* 删除文件。
* @param {string} plugin
* @param {string} path
* @returns {Promise<boolean>}
*/
async delete(plugin, path) {
const safePath = this._safePath(plugin, path);
if (!safePath) return false;
try {
const db = await _openDB();
await _idbRequest(_tx(db, 'readwrite').delete([plugin, safePath]));
return true;
} catch (e) {
console.error(`[FilePipe] delete 失败 (${plugin}/${path}):`, e);
return false;
}
}
/**
* 列出插件下所有文件的路径(可按前缀过滤)。
* @param {string} plugin
* @param {string} [prefix=''] 路径前缀过滤
* @returns {Promise<string[]>}
*/
async list(plugin, prefix = '') {
if (!plugin) return [];
try {
const db = await _openDB();
const store = _tx(db, 'readonly');
const index = store.index('by_plugin');
const range = IDBKeyRange.only(plugin);
return new Promise((resolve, reject) => {
const paths = [];
const req = index.openCursor(range);
req.onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) { resolve(paths); return; }
if (!prefix || cursor.value.path.startsWith(prefix)) {
paths.push(cursor.value.path);
}
cursor.continue();
};
req.onerror = (e) => reject(e.target.error);
});
} catch (e) {
console.error(`[FilePipe] list 失败 (${plugin}):`, e);
return [];
}
}
/**
* 清空插件下的所有文件(插件卸载/重置时调用)。
* @param {string} plugin
* @returns {Promise<number>} 删除的文件数量
*/
async clearAll(plugin) {
const paths = await this.list(plugin);
let count = 0;
for (const path of paths) {
if (await this.delete(plugin, path)) count++;
}
console.info(`[FilePipe] 已清除插件 "${plugin}" 的 ${count} 个文件。`);
return count;
}
/**
* 读取文件元数据(不含 data 本身)。
* @param {string} plugin
* @param {string} path
* @returns {Promise<{path, updatedAt}|null>}
*/
async stat(plugin, path) {
const safePath = this._safePath(plugin, path);
if (!safePath) return null;
try {
const db = await _openDB();
const result = await _idbRequest(_tx(db, 'readonly').get([plugin, safePath]));
if (!result) return null;
return { path: result.path, updatedAt: result.updatedAt };
} catch (e) {
return null;
}
}
/**
* 生成绑定了插件名的快捷访问对象(供 Amily2Bus capability token 注入用)。
* 使用方不需要每次传 plugin 参数。
*
* 示例:
* const file = filePipe.forPlugin('TableSystem');
* await file.write('presets.json', data);
*
* @param {string} plugin
* @returns {{ read, write, delete, list, clearAll, stat }}
*/
forPlugin(plugin) {
return {
read: (path) => this.read(plugin, path),
write: (path, data) => this.write(plugin, path, data),
delete: (path) => this.delete(plugin, path),
list: (prefix) => this.list(plugin, prefix),
clearAll: () => this.clearAll(plugin),
stat: (path) => this.stat(plugin, path),
};
}
}
export default FilePipe;
export default FilePipe;