From 33e80a566e35b871f162aa1e2ac6e8a31be2ab36 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 28 Sep 2026 18:55:30 +0800 Subject: [PATCH] feat(database): add account-scoped database manager --- CHANGELOG.md | 6 ++ README.md | 7 +- drizzle/0002_lifeos_db_admin.sql | 12 +++ drizzle/meta/_journal.json | 7 ++ package.json | 2 +- public/addons.js | 4 +- public/db-admin.js | 139 +++++++++++++++++++++++++++++++ public/i18n-renderers.js | 2 +- public/index.html | 12 +-- public/locales/en.json | 51 +++++++++++- public/locales/zh-CN.json | 51 +++++++++++- public/service-worker.js | 2 +- public/terminal-app.js | 4 +- scripts/build.mjs | 9 +- scripts/test-cloud.mjs | 23 ++++- scripts/validate.mjs | 16 +++- worker/db-admin.js | 117 ++++++++++++++++++++++++++ worker/index.js | 5 +- 18 files changed, 444 insertions(+), 25 deletions(-) create mode 100644 drizzle/0002_lifeos_db_admin.sql create mode 100644 public/db-admin.js create mode 100644 worker/db-admin.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c9be523..100a4ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本项目按 `v主版本.次版本.修订版本` 标记发布。下列内容以对应 Git tag 和已提交功能为准。 +## v1.14.0 — 2026-09-28 + +- 新增数据库管理器 App:固定服务器与用户名的登录页、直接操作数据库的确认提示、按表分页浏览及 JSON 增删改查。 +- 管理操作只作用于当前 ChatGPT 登录用户的记录;口令保存在服务端密钥中,短时会话、失败次数限制、版本冲突检测与写入二次确认保护数据。 +- 记忆、目标、待办、收藏、档案与设置可在管理器查看;标签由记忆内容维护,旧版备份表不开放直接编辑。 + ## v1.13.0 — 2026-09-28 - D1 新增按记录存储的记忆、标签、目标、待办和收藏表;资料与设置保留 JSON。 diff --git a/README.md b/README.md index ac35193..0ca34fd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 以桌面操作系统为交互方式的个人记录网站。用 App 记录日常、管理目标与待办,并沿时间线回看已经留下的内容。 -在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.13.0** · [更新日志](CHANGELOG.md) +在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.14.0** · [更新日志](CHANGELOG.md) ## 功能 @@ -13,6 +13,7 @@ | 博物馆 | 回看收藏的记忆。 | | 目标与待办 | 创建目标和任务,设置截止日期、优先级和完成状态;目标倒计时可选择显示在桌面。 | | 日历 | 在月视图与日视图中查看记忆、任务和目标节点,并为选定日期新增记忆。 | +| 数据库管理器 | 在再次登录并确认风险后,按表查看、创建、编辑和删除当前账号的云端记录。 | | 洞察 | 周报和月报展示记录频率、完成任务数、常见天气、能量记录及收藏或标记高光的记忆;仅依据已保存的数据统计。 | | 档案 | 查看记录概况并维护个人资料。 | | 浏览器 | 在桌面窗口里打开网页或使用 Google 搜索,支持后退、前进、刷新和外部打开。默认打开 Google;第三方网站可能禁止 iframe 嵌入。 | @@ -35,6 +36,8 @@ 主要本地数据键:`lifeos_memories`、`lifeos_museum`、`lifeos_profile`、`lifeos_settings`、`lifeos_system`。云端表结构见 `drizzle/0001_lifeos_records.sql`:记忆、标签、目标、待办、收藏各自按记录保存,资料与设置保留按用户存储的 JSON。旧版 `lifeos_states` 作为只读迁移来源保留;用户首次访问新版本时自动回填新表,旧表不会被清空。单条记录的修改使用版本号校验;冲突时会保存本地副本并要求选择。旧版浏览器标签页需刷新,整份状态写入接口已停用,避免覆盖新数据。应用的备份 `schemaVersion: 2` 与数据库迁移编号是两回事。建议定期导出备份。 +数据库管理器的服务器固定为本站 D1,用户名固定为 `lifeos-admin`,密码保存在托管环境的 `LIFEOS_DB_ADMIN_PASSWORD` 密钥中,不写进仓库或浏览器。它在原有账号登录之上再次验证密码;会话仅在当前页面内存中保留 20 分钟,退出后立即失效,连续五次输错锁定 15 分钟。管理 API 只允许当前账号在预设表内按记录操作,没有任意 SQL 执行入口。更改云端记录后应刷新桌面,使其他 App 重新加载数据。派生的标签索引和旧版状态备份不能在管理器中单独修改。 + ## 构建与验证 项目无需安装第三方 npm 依赖,使用 Node.js 运行: @@ -55,6 +58,7 @@ public/tasks.js 目标与待办 public/calendar.js 日历 public/browser-app.js iframe 浏览器 public/terminal-app.js 命令解析与终端界面 +public/db-admin.js 数据库管理器界面 public/data-actions.js 图形界面与终端共用的数据写入 public/i18n-renderers.js 记忆、洞察、设置等双语界面 public/desktop-personalization.js 图标、窗口与壁纸偏好 @@ -64,6 +68,7 @@ public/locales/ 中文、英文文案 public/service-worker.js PWA 缓存 worker/index.js Worker 路由与必应图片代理 worker/cloud-state.js 云端记录迁移与同步 API +worker/db-admin.js 当前用户的数据库管理 API drizzle/ D1 数据表迁移 scripts/ 构建与产物验证 ``` diff --git a/drizzle/0002_lifeos_db_admin.sql b/drizzle/0002_lifeos_db_admin.sql new file mode 100644 index 0000000..b7cf596 --- /dev/null +++ b/drizzle/0002_lifeos_db_admin.sql @@ -0,0 +1,12 @@ +CREATE TABLE lifeos_admin_sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX idx_lifeos_admin_sessions_user ON lifeos_admin_sessions(user_id); + +CREATE TABLE lifeos_admin_attempts ( + user_id TEXT PRIMARY KEY, + failures INTEGER NOT NULL DEFAULT 0, + locked_until INTEGER NOT NULL DEFAULT 0 +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3d683c4..1149b42 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1790640000000, "tag": "0001_lifeos_records", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1790649000000, + "tag": "0002_lifeos_db_admin", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 43b3281..041d430 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "life-os", - "version": "1.13.0", + "version": "1.14.0", "private": true, "type": "module", "scripts": { diff --git a/public/addons.js b/public/addons.js index bb762bc..8c9180d 100644 --- a/public/addons.js +++ b/public/addons.js @@ -3,9 +3,9 @@ const DRAFT_KEY = 'lifeos_today_draft_v1'; const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }; const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); - const APP_VERSION = '1.13.0'; + const APP_VERSION = '1.14.0'; window.LifeOSVersion = APP_VERSION; - const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal']; + const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal', 'database']; const t = (key, values) => LifeI18n.t(key, values); function readDraft() { diff --git a/public/db-admin.js b/public/db-admin.js new file mode 100644 index 0000000..bf418ed --- /dev/null +++ b/public/db-admin.js @@ -0,0 +1,139 @@ +/* Direct database editor. The session token stays in memory and expires server-side. */ +(() => { + const KINDS = ['memories', 'goals', 'tasks', 'museum', 'profile', 'settings', 'system']; + const text = (key, values) => LifeI18n.t(`db.${key}`, values); + const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); + let session = null, expiresAt = 0, kind = 'memories', page = 0, rows = [], total = 0, selected = null, creating = false, error = '', notice = '', draft = null, draftId = null; + const current = () => document.querySelector('#content-database'); + + async function api(path, options = {}) { + const response = await fetch('/api/db-admin/' + path, { + ...options, cache: 'no-store', headers: { + 'content-type': 'application/json', ...(session ? { 'x-lifeos-admin-session': session } : {}), ...options.headers + } + }); + const result = await response.json(); + if (response.status === 401 && path !== 'login') { session = null; expiresAt = 0; error = text('sessionExpired'); render(current()); } + if (!response.ok) throw new Error(result.error || text('requestFailed')); + return result; + } + + function login(node) { + node.innerHTML = `
LIFE OS / D1

${text('title')}

${text('loginBody')}

`; + node.querySelector('form').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget, button = form.querySelector('button[type=submit]'); + button.disabled = true; error = ''; + try { + const result = await api('login', { method: 'POST', body: JSON.stringify({ + username: form.elements.username.value, password: form.elements.password.value, + acknowledged: form.elements.acknowledged.checked + }) }); + form.elements.password.value = ''; + session = result.token; expiresAt = result.expiresAt; notice = text('connected'); + await load(); render(current()); + } catch (cause) { error = cause.message; render(current()); } + finally { button.disabled = false; } + }); + } + + async function load() { + if (!session) return; + const data = await api(`tables/${kind}/rows?page=${page}`); + rows = data.rows; total = data.total; + if (selected) selected = rows.find(row => row.id === selected.id) || null; + } + + function editor() { + if (!selected && !creating) return `
${text('selectRow')}
`; + const record = selected, initial = creating ? (['profile', 'settings', 'system'].includes(kind) ? {} : { id: '' }) : record.value; + return `
+
${creating ? text('newRecord') : escape(record.id)}${creating ? text('newRevision') : `v${record.revision}`}
+ + +

${text('editorHint')}

+
+ ${creating ? `` : ``}
+
`; + } + + function workspace(node) { + node.innerHTML = `
D1 / ${text('ownData')}

${text('title')}

${text('directMode')}

+
${KINDS.map(item => ``).join('')}
+
+ ${notice ? `

${escape(notice)}

` : ''}${error ? `` : ''} +
${text(kind)}${text('count', { count: total })}
+
${rows.length ? rows.map(row => ``).join('') : `

${text('empty')}

`}
+
${page + 1} / ${Math.max(1, Math.ceil(total / 50))}
+
${editor()}
+

${text('reloadHint')}

+
`; + node.querySelectorAll('[data-kind]').forEach(button => button.addEventListener('click', async () => { + kind = button.dataset.kind; page = 0; selected = null; creating = false; draft = null; draftId = null; error = ''; notice = ''; + try { await load(); render(current()); } catch (cause) { error = cause.message; render(current()); } + })); + node.querySelectorAll('[data-row]').forEach(button => button.addEventListener('click', () => { + selected = rows.find(row => row.id === button.dataset.row); creating = false; draft = null; draftId = null; render(current()); + })); + node.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', async () => { + const action = button.dataset.action; + if (action === 'reload') { location.reload(); return; } + if (action === 'logout') { try { await api('logout', { method: 'POST' }); } catch {} session = null; selected = null; notice = ''; render(current()); return; } + if (action === 'new') { creating = true; selected = null; draft = null; draftId = null; render(current()); return; } + if (action === 'cancel') { creating = false; draft = null; draftId = null; render(current()); return; } + if (action === 'delete') { await remove(); return; } + if (['refresh', 'prev', 'next'].includes(action)) { + if (action === 'prev') page--; if (action === 'next') page++; + try { await load(); render(current()); } catch (cause) { error = cause.message; render(current()); } + } + })); + node.querySelector('.db-editor')?.addEventListener('submit', save); + node.querySelector('.db-editor textarea')?.addEventListener('input', event => { draft = event.currentTarget.value; }); + node.querySelector('.db-editor input[name=id]')?.addEventListener('input', event => { draftId = event.currentTarget.value; }); + } + + async function save(event) { + event.preventDefault(); + const form = event.currentTarget, id = form.elements.id.value.trim(), before = selected; + draft = form.elements.json.value; draftId = id; + let value; + try { value = JSON.parse(form.elements.json.value); } + catch { error = text('invalidJson'); render(current()); return; } + if (!value || Array.isArray(value) || typeof value !== 'object' || (!['profile', 'settings', 'system'].includes(kind) && value.id !== id)) { + error = text('idMismatch'); render(current()); return; + } + if (!confirm(text('saveConfirm', { id }))) return; + const method = creating ? 'POST' : 'PATCH'; + try { + await api(`tables/${kind}/rows/${encodeURIComponent(id)}`, { method, + body: JSON.stringify({ baseVersion: before?.revision || 0, value }) }); + creating = false; selected = null; draft = null; draftId = null; error = ''; notice = text('saved'); + await load(); selected = rows.find(row => row.id === id) || null; render(current()); + } catch (cause) { error = cause.message; render(current()); } + } + async function remove() { + if (!selected || !confirm(text('deleteConfirm', { id: selected.id }))) return; + try { + await api(`tables/${kind}/rows/${encodeURIComponent(selected.id)}?version=${selected.revision}`, { method: 'DELETE' }); + selected = null; draft = null; draftId = null; error = ''; notice = text('deleted'); await load(); render(current()); + } catch (cause) { error = cause.message; render(current()); } + } + function render(node) { + if (!node) return; + if (!session || expiresAt <= Date.now()) { session = null; login(node); } + else workspace(node); + } + window.databaseApp = render; + + const style = document.createElement('style'); + style.textContent = `#content-database{padding:0}.db-intro{max-width:550px;margin:35px auto;padding:0 24px}.db-intro .sub{line-height:1.6}.db-login{display:grid;gap:15px;margin-top:24px}.db-login label,.db-editor label{display:grid;gap:7px;color:var(--muted);font-size:14px}.db-login input:not([type=checkbox]),.db-editor input,.db-editor textarea{width:100%;border:1px solid var(--line);background:var(--panel2);color:var(--ink);border-radius:9px;padding:11px}.db-login input[readonly],.db-editor input[readonly]{opacity:.7}.db-warning{border:1px solid #ce865b;background:#b8673024;border-radius:10px;padding:15px;line-height:1.5}.db-warning strong{color:var(--ink)}.db-warning p{margin:7px 0 0;font-size:13px;color:var(--muted)}.db-ack{display:flex!important;align-items:flex-start;gap:9px}.db-ack input{margin-top:4px}.db-error{color:#ff9292;font-size:13px}.db-notice{color:var(--accent);font-size:13px}.db-shell{padding:22px;min-height:100%}.db-head{display:flex;justify-content:space-between;gap:16px;align-items:start}.db-head h1{margin-bottom:7px}.db-head .sub{margin:0 0 18px}.db-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:12px 0;border-top:1px solid var(--line)}.db-tables{display:flex;flex:1;gap:5px;overflow-x:auto}.db-tables button,.db-pages button{white-space:nowrap;border:1px solid var(--line);border-radius:8px;background:var(--panel2);padding:8px 10px;color:var(--ink);font-size:13px}.db-tables button.active{background:var(--accent);color:var(--bg)}.db-columns{display:grid;grid-template-columns:minmax(180px,.8fr) minmax(260px,1.3fr);gap:15px;min-height:380px}.db-list,.db-detail{min-width:0;border:1px solid var(--line);border-radius:11px;background:var(--panel2)}.db-list{display:flex;flex-direction:column}.db-list-head{display:flex;justify-content:space-between;padding:13px;border-bottom:1px solid var(--line);font-size:13px}.db-rows{flex:1;max-height:450px;overflow:auto}.db-row{display:grid;gap:3px;width:100%;border:0;border-bottom:1px solid var(--line);background:transparent;text-align:left;padding:11px 13px;color:var(--ink)}.db-row.active,.db-row:hover{background:color-mix(in srgb,var(--accent) 12%,var(--panel2))}.db-row strong{font-size:14px;overflow:hidden;text-overflow:ellipsis}.db-row small{color:var(--muted);overflow:hidden;text-overflow:ellipsis}.db-empty,.db-placeholder{padding:25px;color:var(--muted);font-size:14px}.db-pages{display:flex;justify-content:space-between;align-items:center;gap:7px;padding:10px;border-top:1px solid var(--line);font-size:12px}.db-pages button:disabled{opacity:.4}.db-detail{padding:15px}.db-editor{display:grid;gap:14px}.db-editor-head{display:flex;justify-content:space-between;gap:10px}.db-editor-head strong{overflow-wrap:anywhere}.db-editor textarea{resize:vertical;font:13px/1.5 'DM Mono',monospace;min-height:240px}.db-note,.db-foot{font-size:12px;line-height:1.5;color:var(--muted)}.db-link{border:0;background:none;color:var(--accent);padding:0;text-decoration:underline}.db-foot{margin:16px 0 0}@media(max-width:680px){.db-shell{padding:15px}.db-columns{grid-template-columns:1fr}.db-rows{max-height:210px}.db-intro{margin:20px auto;padding:0 18px}}`; + document.head.append(style); +})(); diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js index a1e721b..b390d16 100644 --- a/public/i18n-renderers.js +++ b/public/i18n-renderers.js @@ -110,7 +110,7 @@ window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); }; window.systemApp = container => { const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn'); - container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}

${detected}

${t('system.data')}
${t('system.storage')}

${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}

${t('system.dataStorage')}

${t('system.stored')}
${t('system.backups')}

${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}

${t('system.version')}

${t('system.release',{version:window.LifeOSVersion||'1.13.0'})}

`; + container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}

${detected}

${t('system.data')}
${t('system.storage')}

${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}

${t('system.dataStorage')}

${t('system.stored')}
${t('system.backups')}

${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}

${t('system.version')}

${t('system.release',{version:window.LifeOSVersion||'1.14.0'})}

`; }; window.importPreview = () => { const data = importData, board = data.system?.tasksBoard || {}; rootModal().innerHTML = ``; }; window.replaceConfirm = () => { rootModal().innerHTML = ``; }; diff --git a/public/index.html b/public/index.html index e83b1d3..9962a39 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Manrope,system-ui;overflow:hidden}button,input,textarea{font:inherit;color:inherit}button{cursor:pointer}.desktop{height:100vh;background:radial-gradient(ellipse at 20% 0%,#3f4a503d,transparent 40%),radial-gradient(ellipse at 93% 65%,#59604224,transparent 42%),var(--bg);position:relative}.brand{position:absolute;top:28px;left:32px;font:500 11px 'DM Mono';letter-spacing:.18em;color:var(--muted)}.desktop-icons{padding:72px 32px;display:grid;grid-template-columns:repeat(3,96px);gap:22px}.app-icon{border:0;background:transparent;color:var(--ink);display:grid;gap:9px;justify-items:center;font:500 10px 'DM Mono';letter-spacing:.12em}.glyph{width:48px;height:48px;background:var(--panel);border:1px solid var(--line);border-radius:14px;display:grid;place-items:center;box-shadow:0 8px 22px #0002;font-size:20px}.app-icon:hover .glyph,.taskapp.active{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent)}.taskbar{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);height:54px;display:flex;align-items:center;gap:5px;padding:6px;background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:17px;box-shadow:var(--shadow);z-index:1000}.life,.taskapp,.clock{height:40px;border:0;border-radius:11px;background:transparent;color:var(--ink);padding:0 12px;font:500 11px 'DM Mono';letter-spacing:.1em}.life{background:var(--accent);color:#151812;font-weight:500}.taskapp{min-width:42px;font-size:17px;padding:0 10px}.clock{border-left:1px solid var(--line);line-height:1.35;text-align:left;font-size:10px}.launcher,.calendar{position:absolute;bottom:82px;background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:17px;padding:19px;z-index:1200}.launcher{left:calc(50% - 310px);width:300px}.calendar{right:calc(50% - 310px);width:230px}.hidden{display:none!important}.launchhead{display:flex;justify-content:space-between;font:500 11px 'DM Mono';letter-spacing:.12em}.launchgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:18px 0}.launchitem{border:1px solid transparent;background:var(--panel2);border-radius:10px;padding:13px 7px;color:var(--ink);font:500 9px 'DM Mono';letter-spacing:.08em}.launchitem:hover{border-color:var(--accent)}.systemline{border-top:1px solid var(--line);padding-top:12px;color:var(--muted);font:10px 'DM Mono';letter-spacing:.05em}.window{position:absolute;width:min(700px,calc(100vw - 100px));height:min(590px,calc(100vh - 130px));background:var(--panel);border:1px solid var(--line);border-radius:15px;box-shadow:var(--shadow);overflow:hidden;animation:in .18s ease;display:flex;flex-direction:column}.window.max{width:calc(100vw - 36px)!important;height:calc(100vh - 98px)!important;left:18px!important;top:18px!important}.titlebar{height:48px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:0 13px;cursor:grab;flex:none}.titlebar:active{cursor:grabbing}.titleicon{width:22px;height:22px;border:1px solid var(--line);border-radius:7px;display:grid;place-items:center;font-size:12px}.wtitle{font:500 11px 'DM Mono';letter-spacing:.13em;flex:1}.controls{display:flex;gap:5px}.controls button{width:25px;height:25px;border:0;background:transparent;color:var(--muted);border-radius:6px}.controls button:hover{background:var(--panel2);color:var(--ink)}.content{padding:24px;overflow:auto;height:100%}.eyebrow{font:10px 'DM Mono';letter-spacing:.16em;color:var(--muted);margin-bottom:8px}h1,h2,p{margin-top:0}h1{font-size:28px;letter-spacing:-.05em;margin-bottom:3px}h2{font-size:13px;letter-spacing:.07em}.sub{color:var(--muted);font-size:14px}.today-grid{display:grid;grid-template-columns:1.3fr .7fr;gap:24px}.entry{width:100%;height:190px;resize:none;background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:15px;outline:none;line-height:1.55}.entry:focus{border-color:var(--accent)}.choices{display:flex;gap:7px;flex-wrap:wrap}.chip{border:1px solid var(--line);border-radius:99px;background:transparent;padding:8px 10px;color:var(--muted);font:10px 'DM Mono';letter-spacing:.06em}.chip.selected{background:var(--accent);border-color:var(--accent);color:#171b12}.energy{display:flex;gap:7px}.energy button{width:31px;height:31px;border:1px solid var(--line);background:var(--panel2);color:var(--muted);border-radius:6px}.energy button.selected{background:var(--accent);color:#171b12;border-color:var(--accent)}.film{border-top:1px solid var(--line);margin-top:18px;padding-top:16px}.film-title{font-size:20px;letter-spacing:-.03em}.film-copy{color:var(--muted);font-size:13px;line-height:1.55}.primary,.secondary,.danger{border:0;padding:11px 14px;border-radius:9px;font:500 10px 'DM Mono';letter-spacing:.1em}.primary{background:var(--accent);color:#151812}.secondary{background:var(--panel2);border:1px solid var(--line)}.danger{background:#a94e4e;color:#fff}.actions{display:flex;gap:9px;margin-top:18px}.status{font:10px 'DM Mono';letter-spacing:.1em;color:var(--accent);align-self:center}.timeline{position:relative;margin:8px 0}.memory{border-left:1px solid var(--line);padding:0 0 23px 25px;position:relative}.memory:before{content:'';position:absolute;left:-4px;top:5px;width:7px;height:7px;background:var(--accent);border-radius:50%}.memory button{all:unset;cursor:pointer;width:100%}.memory .date{font:10px 'DM Mono';color:var(--muted);letter-spacing:.1em}.memory .mtitle{font-size:18px;margin:5px 0}.memory .snippet{color:var(--muted);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty{display:grid;place-items:center;min-height:280px;color:var(--muted);text-align:center;font-size:14px}.museum{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.artifact{background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:17px;text-align:left;color:var(--ink)}.artifact:hover{border-color:var(--accent)}.artifact span{display:block;font:10px 'DM Mono';color:var(--muted);letter-spacing:.08em}.artifact strong{display:block;font-size:18px;margin:18px 0 7px;letter-spacing:-.04em}.insights{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.metric{border:1px solid var(--line);border-radius:12px;padding:18px;min-height:120px}.metric .big{font-size:36px;letter-spacing:-.07em}.bars{display:flex;align-items:end;height:56px;gap:5px;margin-top:12px}.bar{background:var(--accent);opacity:.78;width:12%;border-radius:3px 3px 0 0}.profile-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:20px 0}.stat{border-top:1px solid var(--line);padding-top:10px}.stat b{display:block;font-size:24px;letter-spacing:-.06em}.stat span{font:10px 'DM Mono';color:var(--muted);letter-spacing:.08em}.formrow{display:grid;grid-template-columns:1fr 1.5fr;gap:12px;align-items:center;margin:9px 0}.formrow input{border:1px solid var(--line);background:var(--panel2);border-radius:8px;padding:10px}.settings section{border-top:1px solid var(--line);padding:17px 0}.settings section:first-child{border-top:0}.settings p{color:var(--muted);font-size:13px;line-height:1.55}.modal{position:fixed;inset:0;background:#0008;display:grid;place-items:center;z-index:5000}.modalbox{position:relative;width:min(410px,calc(100vw - 36px));background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:23px;box-shadow:var(--shadow)}.modal-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border:1px solid var(--line);border-radius:50%;background:transparent;color:var(--ink);font-size:24px;line-height:1;cursor:pointer}.modal-close:hover,.modal-close:focus-visible{background:var(--accent);color:#fff;outline:none}.toast{position:fixed;top:24px;left:50%;transform:translateX(-50%);background:var(--accent);color:#131611;padding:12px 15px;border-radius:9px;z-index:6000;font:500 10px 'DM Mono';letter-spacing:.1em;animation:in .18s ease}.boot{position:fixed;inset:0;background:var(--bg);z-index:9000;display:grid;place-content:center;text-align:center;gap:13px;font:12px 'DM Mono';letter-spacing:.16em}.boot b{font-size:28px;color:var(--ink)}@keyframes in{from{opacity:0;transform:scale(.98)}to{opacity:1;transform:scale(1)}} @media(max-width:650px){body{overflow:auto}.desktop{min-height:100vh}.brand{left:22px;top:20px}.desktop-icons{padding:79px 20px 100px;grid-template-columns:repeat(3,1fr);gap:28px 16px}.taskbar{bottom:11px;width:calc(100% - 22px);justify-content:space-between}.taskapp{display:inline-grid;place-items:center;min-width:32px;padding:0 6px}.clock{display:none}.window{position:fixed!important;inset:0!important;width:100%!important;height:100%!important;border:0;border-radius:0;z-index:2000!important}.window.min{display:none}.titlebar{padding-top:env(safe-area-inset-top);height:53px}.content{padding:20px}.today-grid{grid-template-columns:1fr}.launcher{left:11px;bottom:76px;width:calc(100% - 22px)}.calendar{right:11px;bottom:76px}.museum,.insights,.profile-grid{grid-template-columns:1fr 1fr}.profile-grid{gap:15px}.formrow{grid-template-columns:1fr}.boot{font-size:10px}}
LIFE OSINITIALIZING PERSONAL SYSTEM...
+ diff --git a/public/locales/en.json b/public/locales/en.json index 7086ed5..d911b26 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -9,7 +9,56 @@ "calendar": "CALENDAR", "system": "SYSTEM", "browser": "BROWSER", - "terminal": "TERMINAL" + "terminal": "TERMINAL", + "database": "DATABASE" + }, + "db": { + "title": "Database Manager", + "loginBody": "Inspect and edit the records stored for your current account.", + "server": "Server", + "username": "Username", + "password": "Password", + "warningTitle": "Direct database access", + "warningBody": "Changes here write to the cloud database immediately. Invalid JSON or deleted records can affect your Life OS data. Export a backup before making major changes.", + "acknowledge": "I understand I am directly editing my database records.", + "login": "Enter database", + "connected": "Connected to your account's records.", + "sessionExpired": "The database session expired. Sign in again.", + "requestFailed": "Database request failed.", + "ownData": "CURRENT ACCOUNT ONLY", + "directMode": "Direct record editor · changes are saved to D1", + "logout": "Log out", + "tables": "Tables", + "memories": "Memories", + "goals": "Goals", + "tasks": "Tasks", + "museum": "Favorites", + "profile": "Profile", + "settings": "Settings", + "system": "System", + "refresh": "Refresh", + "newRecord": "New record", + "newRevision": "new", + "count": "{count} records", + "empty": "No records in this table.", + "selectRow": "Select a row to inspect its JSON.", + "recordId": "Record ID", + "json": "Record JSON", + "editorHint": "Keep the ID in the JSON identical to the record ID. A conflict means this row changed since you opened it.", + "create": "Create", + "save": "Save", + "delete": "Delete", + "cancel": "Cancel", + "previous": "Previous", + "next": "Next", + "invalidJson": "The JSON is invalid.", + "idMismatch": "The JSON object and record ID do not match.", + "saveConfirm": "Write record {id} directly to the database?", + "deleteConfirm": "Delete record {id} from the database? This cannot be undone here.", + "saved": "Record saved.", + "deleted": "Record deleted.", + "reloadHint": "The desktop keeps a local cache. Reload after database changes to refresh other apps.", + "reloadDesktop": "Reload desktop" }, "common": { "save": "SAVE", diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 9d0bc46..edf5236 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -9,7 +9,56 @@ "calendar": "日历", "system": "系统", "browser": "浏览器", - "terminal": "终端" + "terminal": "终端", + "database": "数据库" + }, + "db": { + "title": "数据库管理器", + "loginBody": "查看和编辑当前账号保存在云端的记录。", + "server": "服务器", + "username": "用户名", + "password": "密码", + "warningTitle": "正在直接接触数据库", + "warningBody": "此处的修改会立即写入云端数据库。无效 JSON 或误删记录可能影响 Life OS 数据。大幅修改前请先导出备份。", + "acknowledge": "我了解自己正在直接修改数据库记录。", + "login": "进入数据库", + "connected": "已连接当前账号的记录。", + "sessionExpired": "数据库会话已过期,请重新登录。", + "requestFailed": "数据库请求失败。", + "ownData": "仅当前账号", + "directMode": "直接编辑记录 · 修改写入 D1", + "logout": "退出登录", + "tables": "数据表", + "memories": "记忆", + "goals": "目标", + "tasks": "待办", + "museum": "收藏", + "profile": "档案", + "settings": "设置", + "system": "系统", + "refresh": "刷新", + "newRecord": "新增记录", + "newRevision": "新建", + "count": "{count} 条记录", + "empty": "此表没有记录。", + "selectRow": "选择一条记录查看 JSON。", + "recordId": "记录 ID", + "json": "记录 JSON", + "editorHint": "JSON 中的 ID 必须与记录 ID 一致。冲突表示打开后这条记录又被修改。", + "create": "创建", + "save": "保存", + "delete": "删除", + "cancel": "取消", + "previous": "上一页", + "next": "下一页", + "invalidJson": "JSON 格式无效。", + "idMismatch": "JSON 对象与记录 ID 不匹配。", + "saveConfirm": "直接将记录 {id} 写入数据库吗?", + "deleteConfirm": "从数据库删除记录 {id}?此处无法撤销。", + "saved": "记录已保存。", + "deleted": "记录已删除。", + "reloadHint": "桌面保留本地缓存。修改数据库后,请刷新页面以更新其他 App。", + "reloadDesktop": "刷新桌面" }, "common": { "save": "保存", diff --git a/public/service-worker.js b/public/service-worker.js index ec67afc..7600acd 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE = 'life-os-shell-v1.13.0'; +const CACHE = 'life-os-shell-v1.14.0'; const SHELL = [ '/', '/index.html', '/manifest.webmanifest', '/life-os-icon.svg' ]; diff --git a/public/terminal-app.js b/public/terminal-app.js index f82c249..6531c2e 100644 --- a/public/terminal-app.js +++ b/public/terminal-app.js @@ -150,7 +150,7 @@ return { message: new Intl.DateTimeFormat(lang() === 'zh' ? 'zh-CN' : 'en-US', options).format(new Date()) }; } if (kind === 'uptime') { if (verb) throw new Error('usage'); const seconds = Math.floor((Date.now() - startedAt) / 1000); return { message: t('uptime', { minutes: Math.floor(seconds / 60), seconds: seconds % 60 }) }; } - if (kind === 'version') { if (verb) throw new Error('usage'); return { message: `Life OS v${window.LifeOSVersion || '1.13.0'}` }; } + if (kind === 'version') { if (verb) throw new Error('usage'); return { message: `Life OS v${window.LifeOSVersion || '1.14.0'}` }; } if (kind === 'status') { if (verb) throw new Error('usage'); const data = board(); return { message: t('status', { memories: mems().length, goals: data.goals.length, tasks: data.tasks.length }) }; } if (kind === 'apps') { if (verb) throw new Error('usage'); return { message: Object.keys(APPS).map(id => `${id} — ${LifeI18n.t(`app.${id}`)}`).join('\n') }; } if (kind === 'history') { if (verb) throw new Error('usage'); return { message: history.slice(-20).map((entry,index) => `${index + 1}. ${entry}`).join('\n') || t('empty') }; } @@ -235,7 +235,7 @@ }; window.terminalApp = node => { if (node.querySelector('.terminal-shell') && node.dataset.terminalLocale === lang()) { output(node); return; } - if (!welcomed) { lines.unshift({ value: t('welcome', { version: window.LifeOSVersion || '1.13.0' }), kind: 'system' }); welcomed = true; } + if (!welcomed) { lines.unshift({ value: t('welcome', { version: window.LifeOSVersion || '1.14.0' }), kind: 'system' }); welcomed = true; } node.dataset.terminalLocale = lang(); node.innerHTML = `
Life OS / ${t('title')}${t('hint')}
`; output(node); composer(node, Boolean(compose)); diff --git a/scripts/build.mjs b/scripts/build.mjs index 248c9a7..38039e5 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,22 +7,23 @@ const meta = new URL("../dist/.openai/", import.meta.url); await rm(dist, { recursive: true, force: true }); await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]); -const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, dataActionsJs, terminalAppJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); -const release = "1.13.0"; +const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, dataActionsJs, terminalAppJs, dbAdminJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("db-admin.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); +const release = "1.14.0"; const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`; const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`; let indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`).replace('src="tasks.js"', `src="tasks.js?v=${release}"`).replace('src="calendar.js"', `src="calendar.js?v=${release}"`); for (const [src, code] of [ ['boot-guard.js', bootGuardJs], ['locales/en.js', localeEnJs], ['locales/zh-CN.js', localeZhJs], ['i18n.js', i18nJs], ['tasks.js', tasksJs], ['calendar.js', calendarJs], - ['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs], ['browser-app.js', browserAppJs], ['data-actions.js', dataActionsJs], ['terminal-app.js', terminalAppJs] + ['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs], ['browser-app.js', browserAppJs], ['data-actions.js', dataActionsJs], ['terminal-app.js', terminalAppJs], ['db-admin.js', dbAdminJs] ]) { const tag = ``; if (!indexHtml.includes(tag)) throw new Error(`Missing Life OS script: ${src}`); indexHtml = indexHtml.replace(tag, ``); } -await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); +await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const dbAdminJs=${JSON.stringify(dbAdminJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server)); await copyFile(new URL("../worker/cloud-state.js", import.meta.url), new URL("cloud-state.js", server)); +await copyFile(new URL("../worker/db-admin.js", import.meta.url), new URL("db-admin.js", server)); await copyFile(new URL("../.openai/hosting.json", import.meta.url), new URL("hosting.json", meta)); console.log("Built Life OS Worker artifact"); diff --git a/scripts/test-cloud.mjs b/scripts/test-cloud.mjs index a96a6b4..4770f2b 100644 --- a/scripts/test-cloud.mjs +++ b/scripts/test-cloud.mjs @@ -8,6 +8,7 @@ const sqlite = new DatabaseSync(":memory:"); sqlite.exec("PRAGMA foreign_keys = ON"); sqlite.exec(readFileSync(new URL("../drizzle/0000_lifeos_cloud_state.sql", import.meta.url), "utf8")); sqlite.exec(readFileSync(new URL("../drizzle/0001_lifeos_records.sql", import.meta.url), "utf8")); +sqlite.exec(readFileSync(new URL("../drizzle/0002_lifeos_db_admin.sql", import.meta.url), "utf8")); const db = { prepare(sql) { return { @@ -36,7 +37,7 @@ async function call(path, method = "GET", data, as = headers) { const response = await worker.fetch(new Request("https://life-os.test" + path, { method, headers: { ...as, "content-type": "application/json" }, body: data === undefined ? undefined : JSON.stringify(data) - }), { DB: db }); + }), { DB: db, LIFEOS_DB_ADMIN_PASSWORD: "local test password with 28 chars" }); return { status: response.status, body: await response.json() }; } @@ -102,4 +103,24 @@ const pending = client.window.CloudTest.changes(snapshot.state, client.window.Cl assert.deepEqual(Array.from(pending, item => item.kind), ["tasks"], "editing a task only writes one record"); await client.window.CloudTest.sync(); assert.equal((await call("/api/lifeos-state")).body.state.system.tasksBoard.tasks[0].done, true); +assert.equal((await call("/api/db-admin/tables/tasks/rows")).status, 401); +assert.equal((await call("/api/db-admin/login", "POST", { username: "lifeos-admin", password: "bad", acknowledged: true })).status, 401); +const login = await call("/api/db-admin/login", "POST", { + username: "lifeos-admin", password: "local test password with 28 chars", acknowledged: true +}); +assert.equal(login.status, 200); +const auth = { ...headers, "x-lifeos-admin-session": login.body.token }; +assert.equal((await call("/api/db-admin/tables/tasks/rows", "GET", undefined, { ...auth, origin: "https://evil.example" })).status, 403); +let admin = await call("/api/db-admin/tables/tasks/rows", "GET", undefined, auth); +assert.equal(admin.status, 200); +assert.equal(admin.body.total, 2); +assert.equal((await call("/api/db-admin/tables/tasks/rows", "GET", undefined, { ...auth, "oai-authenticated-user-id": "bob" })).status, 401, "admin session is tied to one user"); +admin = await call("/api/db-admin/tables/tasks/rows/t3", "POST", { baseVersion: 0, value: { id: "t3", title: "From database" } }, auth); +assert.equal(admin.body.version, 1); +admin = await call("/api/db-admin/tables/tasks/rows/t3", "PATCH", { baseVersion: 1, value: { id: "t3", title: "Edited" } }, auth); +assert.equal(admin.body.version, 2); +assert.equal((await call("/api/db-admin/tables/tasks/rows/t3", "PATCH", { baseVersion: 1, value: { id: "t3", title: "Stale" } }, auth)).status, 409); +assert.equal((await call("/api/db-admin/tables/tasks/rows/t3?version=2", "DELETE", undefined, auth)).status, 200); +assert.equal((await call("/api/db-admin/logout", "POST", {}, auth)).status, 200); +assert.equal((await call("/api/db-admin/tables/tasks/rows", "GET", undefined, auth)).status, 401); console.log("Life OS record migration, sync conflict, tags, and user isolation valid"); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index bf46445..328d318 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -50,11 +50,21 @@ assert.doesNotMatch(assets.indexHtml, /setTimeout\(applyLanguage\s*,/); assert.doesNotMatch(assets.addonsJs, /(?([\s\S]*?)<\/script>/g)].map(match => match[1]); -assert.equal(shippedScripts.length, 15, "the complete desktop script chain is present in the served HTML"); +assert.equal(shippedScripts.length, 16, "the complete desktop script chain is present in the served HTML"); assert.doesNotMatch(assets.indexHtml, / [key, { value: '', href: '', disabled: false, listeners: {}, addEventListener(type, handler) { this.listeners[type] = handler; }, getAttribute(name) { return this[name] ?? null; } }])); const browserContainer = { dataset: {}, innerHTML: '', querySelector(selector) { return selector === '.browser-shell' ? (this.innerHTML ? {} : null) : browserNodes[selector]; } }; const browserMessages = []; @@ -173,7 +183,7 @@ assert.ok(command('date').message.length > 8); assert.ok(command('time').message.includes(':')); assert.ok(command('datetime').message.length > 12); assert.equal(command('uptime').message, 'terminal.uptime'); -assert.equal(command('version').message, 'Life OS v1.13.0'); +assert.equal(command('version').message, 'Life OS v1.14.0'); assert.equal(command('status').message, 'terminal.status'); assert.match(command('apps').message, /calendar/); assert.equal(command('echo "hello Life OS"').message, 'hello Life OS'); diff --git a/worker/db-admin.js b/worker/db-admin.js new file mode 100644 index 0000000..6026cb6 --- /dev/null +++ b/worker/db-admin.js @@ -0,0 +1,117 @@ +import { cloudApi } from "./cloud-state.js"; + +const TABLES = { + memories: "lifeos_memories", goals: "lifeos_goals", tasks: "lifeos_tasks", + museum: "lifeos_favorites", profile: "lifeos_meta", + settings: "lifeos_meta", system: "lifeos_meta" +}; +const META = new Set(["profile", "settings", "system"]); +const DURATION = 20 * 60 * 1000; +const body = (value, status = 200) => Response.json(value, { status, headers: { "cache-control": "no-store" } }); +const validId = value => typeof value === "string" && value.length > 0 && value.length <= 200; +const hash = async value => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))].map(byte => byte.toString(16).padStart(2, "0")).join(""); +const equal = async (a, b) => { + const [first, second] = await Promise.all([hash(a), hash(b)]); + let difference = 0; + for (let i = 0; i < first.length; i++) difference |= first.charCodeAt(i) ^ second.charCodeAt(i); + return difference === 0; +}; +const token = () => { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; +async function signedIn(request, db, userId) { + const raw = request.headers.get("x-lifeos-admin-session"); + if (!raw || raw.length > 100) return false; + const record = await db.prepare("SELECT expires_at FROM lifeos_admin_sessions WHERE token_hash = ? AND user_id = ?").bind(await hash(raw), userId).first(); + return !!record && record.expires_at > Date.now(); +} +function originAllowed(request) { + const origin = request.headers.get("origin"); + return !origin || origin === new URL(request.url).origin; +} +async function login(request, env, userId) { + const secret = env.LIFEOS_DB_ADMIN_PASSWORD; + if (typeof secret !== "string" || secret.length < 20) return body({ error: "Admin login is not configured" }, 503); + const row = await env.DB.prepare("SELECT failures, locked_until FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).first(); + if (row?.locked_until > Date.now()) return body({ error: "Too many attempts", retryAt: row.locked_until }, 429); + const text = await request.text(); + if (text.length > 1024) return body({ error: "Invalid credentials" }, 400); + let input; + try { input = JSON.parse(text); } catch { return body({ error: "Invalid JSON" }, 400); } + if (input?.acknowledged !== true) return body({ error: "Direct database access must be acknowledged" }, 400); + const matched = input.username === "lifeos-admin" && typeof input.password === "string" && + await equal(input.password, secret); + if (!matched) { + const failures = (row?.failures || 0) + 1, until = failures >= 5 ? Date.now() + 15 * 60_000 : 0; + await env.DB.prepare("INSERT INTO lifeos_admin_attempts(user_id, failures, locked_until) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET failures = excluded.failures, locked_until = excluded.locked_until").bind(userId, failures >= 5 ? 0 : failures, until).run(); + return body({ error: "Invalid credentials", retryAt: until || null }, 401); + } + await env.DB.prepare("DELETE FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).run(); + const raw = token(), expiresAt = Date.now() + DURATION; + await env.DB.prepare("INSERT INTO lifeos_admin_sessions(token_hash, user_id, expires_at) VALUES (?, ?, ?)").bind(await hash(raw), userId, expiresAt).run(); + return body({ token: raw, expiresAt }); +} +async function list(db, userId, kind, page) { + const table = TABLES[kind], offset = page * 50; + const filter = META.has(kind) ? "user_id = ? AND kind = ?" : "user_id = ? AND deleted_at IS NULL"; + const bindings = META.has(kind) ? [userId, kind] : [userId]; + const rows = await db.prepare(`SELECT ${META.has(kind) ? "kind" : "id"} AS id, value_json, revision, updated_at FROM ${table} WHERE ${filter} ORDER BY updated_at DESC LIMIT 50 OFFSET ?`).bind(...bindings, offset).all(); + const count = await db.prepare(`SELECT COUNT(*) AS total FROM ${table} WHERE ${filter}`).bind(...bindings).first(); + return body({ rows: rows.results.map(row => ({ + id: row.id, value: JSON.parse(row.value_json), revision: row.revision, updatedAt: row.updated_at + })), total: count.total, page }); +} +async function mutate(request, env, userId, kind, id, method) { + if (!validId(id) || (META.has(kind) && id !== kind)) return body({ error: "Invalid record ID" }, 400); + let input = {}; + if (method !== "DELETE") { + const text = await request.text(); + if (text.length > 1_100_000) return body({ error: "Record is too large" }, 413); + try { input = JSON.parse(text); } catch { return body({ error: "Invalid JSON" }, 400); } + } else { + input.baseVersion = Number(new URL(request.url).searchParams.get("version")); + } + const baseVersion = input.baseVersion; + if (!Number.isInteger(baseVersion) || baseVersion < 0 || (method !== "POST" && baseVersion === 0)) return body({ error: "Invalid revision" }, 400); + if (method === "POST" && baseVersion !== 0) return body({ error: "New records start at revision zero" }, 400); + if (method === "DELETE" && META.has(kind)) { + const result = await env.DB.prepare("DELETE FROM lifeos_meta WHERE user_id = ? AND kind = ? AND revision = ?").bind(userId, kind, baseVersion).run(); + return result.meta.changes ? body({ ok: true }) : body({ error: "Conflict" }, 409); + } + if (method !== "DELETE" && (!input.value || typeof input.value !== "object" || Array.isArray(input.value) || + (!META.has(kind) && input.value.id !== id))) return body({ error: "Invalid record JSON" }, 400); + return cloudApi(new Request(new URL("/api/lifeos-sync", request.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind, id, baseVersion, value: method === "DELETE" ? null : input.value }) + }), env, userId, "/api/lifeos-sync"); +} +export async function dbAdminApi(request, env, userId, pathname) { + if (!userId) return body({ error: "Authentication required" }, 401); + if (!env.DB) return body({ error: "Database unavailable" }, 503); + if (!originAllowed(request)) return body({ error: "Origin rejected" }, 403); + const segments = pathname.slice("/api/db-admin".length).split("/").filter(Boolean); + try { + if (segments[0] === "login" && segments.length === 1 && request.method === "POST") return login(request, env, userId); + if (!await signedIn(request, env.DB, userId)) return body({ error: "Admin session required" }, 401); + if (segments[0] === "session" && segments.length === 1 && request.method === "GET") return body({ ok: true }); + if (segments[0] === "logout" && segments.length === 1 && request.method === "POST") { + await env.DB.prepare("DELETE FROM lifeos_admin_sessions WHERE token_hash = ? AND user_id = ?").bind(await hash(request.headers.get("x-lifeos-admin-session")), userId).run(); + return body({ ok: true }); + } + const kind = segments[1]; + if (segments[0] !== "tables" || !Object.hasOwn(TABLES, kind)) return body({ error: "Unknown table" }, 404); + if (segments.length === 3 && segments[2] === "rows" && request.method === "GET") { + const page = Number(new URL(request.url).searchParams.get("page") || 0); + if (!Number.isInteger(page) || page < 0 || page > 1000) return body({ error: "Invalid page" }, 400); + return list(env.DB, userId, kind, page); + } + if (segments.length === 4 && segments[2] === "rows" && ["POST", "PATCH", "DELETE"].includes(request.method)) { + return mutate(request, env, userId, kind, decodeURIComponent(segments[3]), request.method); + } + return body({ error: "Method not allowed" }, 405); + } catch (error) { + console.error("Life OS database manager failed", error); + return body({ error: "Database operation failed" }, 500); + } +} diff --git a/worker/index.js b/worker/index.js index a942ee7..78ad427 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,5 +1,6 @@ -import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, browserAppJs, dataActionsJs, terminalAppJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js"; +import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, browserAppJs, dataActionsJs, terminalAppJs, dbAdminJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js"; import { cloudApi } from "./cloud-state.js"; +import { dbAdminApi } from "./db-admin.js"; const BING_ARCHIVE = "https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US"; @@ -26,6 +27,7 @@ export default { const url = new URL(request.url); const id = userId(request); if (["/api/lifeos-state", "/api/lifeos-initialize", "/api/lifeos-sync"].includes(url.pathname)) return cloudApi(request, env, id, url.pathname); + if (url.pathname.startsWith("/api/db-admin/")) return dbAdminApi(request, env, id, url.pathname); try { if (url.pathname === "/api/bing-wallpaper") return Response.json(await bingMetadata(), { headers: { "cache-control": "public, max-age=3600" } }); if (url.pathname === "/api/bing-wallpaper-image") { @@ -49,6 +51,7 @@ export default { if (url.pathname === "/calendar.js") return new Response(calendarJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/data-actions.js") return new Response(dataActionsJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/terminal-app.js") return new Response(terminalAppJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); + if (url.pathname === "/db-admin.js") return new Response(dbAdminJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/browser-app.js") return new Response(browserAppJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/i18n-renderers.js") return new Response(i18nRenderersJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/" || url.pathname === "/index.html") {