diff --git a/CHANGELOG.md b/CHANGELOG.md index 100a4ee..e1b51ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本项目按 `v主版本.次版本.修订版本` 标记发布。下列内容以对应 Git tag 和已提交功能为准。 +## v1.14.1 — 2026-09-28 + +- 数据库管理器改为每个 ChatGPT 账号首次进入时设置自己的密码,停用旧的共用口令。 +- 密码以独立盐值和 PBKDF2 摘要保存;可在管理页验证旧密码后修改,修改时注销旧会话。 +- 增加首次设置、不同账号密码隔离、旧会话失效和修改密码的验证。 + ## v1.14.0 — 2026-09-28 - 新增数据库管理器 App:固定服务器与用户名的登录页、直接操作数据库的确认提示、按表分页浏览及 JSON 增删改查。 diff --git a/README.md b/README.md index 0ca34fd..8171c3a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 以桌面操作系统为交互方式的个人记录网站。用 App 记录日常、管理目标与待办,并沿时间线回看已经留下的内容。 -在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.14.0** · [更新日志](CHANGELOG.md) +在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.14.1** · [更新日志](CHANGELOG.md) ## 功能 @@ -36,7 +36,7 @@ 主要本地数据键:`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 重新加载数据。派生的标签索引和旧版状态备份不能在管理器中单独修改。 +数据库管理器的服务器固定为本站 D1,用户名固定为 `lifeos-admin`。每个 ChatGPT 账号首次打开管理器时设置自己的密码(至少 12 个字符),旧版共用口令不再有效。密码以独立盐值与 PBKDF2 摘要存入 `lifeos_admin_credentials`,不保存明文,也不写进仓库或浏览器;管理页可验证当前密码后更换。它在原有账号登录之上再次验证密码;会话仅在当前页面内存中保留 20 分钟,退出或修改密码后旧会话立即失效,连续五次输错锁定 15 分钟。管理 API 只允许当前账号在预设表内按记录操作,没有任意 SQL 执行入口。更改云端记录后应刷新桌面,使其他 App 重新加载数据。派生的标签索引和旧版状态备份不能在管理器中单独修改。 ## 构建与验证 diff --git a/drizzle/0003_lifeos_admin_credentials.sql b/drizzle/0003_lifeos_admin_credentials.sql new file mode 100644 index 0000000..1475905 --- /dev/null +++ b/drizzle/0003_lifeos_admin_credentials.sql @@ -0,0 +1,7 @@ +CREATE TABLE lifeos_admin_credentials ( + user_id TEXT PRIMARY KEY, + salt TEXT NOT NULL, + password_hash TEXT NOT NULL, + iterations INTEGER NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1149b42..c3c23a8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1790649000000, "tag": "0002_lifeos_db_admin", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1790650800000, + "tag": "0003_lifeos_admin_credentials", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 041d430..5bd5f04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "life-os", - "version": "1.14.0", + "version": "1.14.1", "private": true, "type": "module", "scripts": { diff --git a/public/addons.js b/public/addons.js index 8c9180d..b8c930a 100644 --- a/public/addons.js +++ b/public/addons.js @@ -3,7 +3,7 @@ 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.14.0'; + const APP_VERSION = '1.14.1'; window.LifeOSVersion = APP_VERSION; const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal', 'database']; const t = (key, values) => LifeI18n.t(key, values); diff --git a/public/db-admin.js b/public/db-admin.js index bf418ed..bf00f7c 100644 --- a/public/db-admin.js +++ b/public/db-admin.js @@ -3,7 +3,7 @@ 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; + let session = null, expiresAt = 0, configured = null, checking = false, changeMode = false, 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 = {}) { @@ -13,19 +13,20 @@ } }); const result = await response.json(); - if (response.status === 401 && path !== 'login') { session = null; expiresAt = 0; error = text('sessionExpired'); render(current()); } + if (response.status === 401 && !['login', 'setup', 'password'].includes(path)) { 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.innerHTML = `
LIFE OS / D1

${configured ? text('title') : text('setupTitle')}

${configured ? text('loginBody') : text('setupBody')}

- + + ${configured ? '' : ``} -
+
${error ? `` : ''}
`; node.querySelector('form').addEventListener('submit', async event => { @@ -33,17 +34,40 @@ 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({ + if (!configured && form.elements.password.value !== form.elements.confirmPassword.value) throw new Error(text('passwordMismatch')); + const result = await api(configured ? 'login' : 'setup', { 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'); + configured = true; session = result.token; expiresAt = result.expiresAt; notice = text('connected'); await load(); render(current()); } catch (cause) { error = cause.message; render(current()); } finally { button.disabled = false; } }); } + function changePassword(node) { + node.innerHTML = `
LIFE OS / D1

${text('changePassword')}

`; + node.querySelector('button[type=button]').addEventListener('click', () => { changeMode = false; error = ''; render(current()); }); + node.querySelector('form').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + try { + if (form.elements.newPassword.value !== form.elements.confirmPassword.value) throw new Error(text('passwordMismatch')); + const result = await api('password', { method: 'POST', body: JSON.stringify({ + currentPassword: form.elements.currentPassword.value, newPassword: form.elements.newPassword.value + }) }); + form.reset(); session = result.token; expiresAt = result.expiresAt; changeMode = false; + notice = text('passwordChanged'); error = ''; render(current()); + } catch (cause) { error = cause.message; render(current()); } + }); + } async function load() { if (!session) return; @@ -66,7 +90,7 @@ } function workspace(node) { - node.innerHTML = `
D1 / ${text('ownData')}

${text('title')}

${text('directMode')}

+ node.innerHTML = `
D1 / ${text('ownData')}

${text('title')}

${text('directMode')}

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

${escape(notice)}

` : ''}${error ? `` : ''} @@ -87,6 +111,7 @@ 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 === 'password') { changeMode = true; error = ''; 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; } @@ -128,7 +153,23 @@ } function render(node) { if (!node) return; + if (configured === null) { + node.innerHTML = `

${text('checking')}

`; + if (!checking) { + checking = true; + api('config').then(result => { configured = result.configured; error = ''; }) + .catch(cause => { configured = 'unavailable'; error = cause.message; }) + .finally(() => { checking = false; render(current()); }); + } + return; + } + if (configured === 'unavailable') { + node.innerHTML = `
`; + node.querySelector('button').addEventListener('click', () => { configured = null; render(current()); }); + return; + } if (!session || expiresAt <= Date.now()) { session = null; login(node); } + else if (changeMode) changePassword(node); else workspace(node); } window.databaseApp = render; diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js index b390d16..fe8f12e 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.14.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.1'})}

`; }; 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 9962a39..01b591e 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ function browserLanguage(){let ls=navigator.languages?.length?navigator.language function appName(id){return i18n(APPS[id][1])}function moodName(m){return i18n(m.toUpperCase(),cap(m))}function day(){const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}function fmtDate(d){return new Date(d+'T12:00:00').toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'})}function shortDate(d){return new Date(d+'T12:00:00').toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function cap(x){return x[0].toUpperCase()+x.slice(1)}function esc(s){return (s||'').replace(/[&<>]/g,x=>({'&':'&','<':'<','>':'>'}[x]))} function applyWallpaper(w){if(!w?.url)return;desktop.style.backgroundImage=`linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url('${w.url}')`;desktop.style.backgroundSize='cover';desktop.style.backgroundPosition='center';desktop.title=LifeI18n.t('shell.wallpaperTitle',{title:w.title||LifeI18n.t('wallpaper.dailyImage')})}async function loadBingWallpaper(){let s=settings();if(s.wallpaper?.date===day()&&s.wallpaper?.source==='lifeos-proxy'){applyWallpaper(s.wallpaper);return}try{let r=await fetch('/api/bing-wallpaper'),d=await r.json();if(!r.ok||!d.url)throw Error(d.error||'No Bing image');s=settings();if((s.wallpaperSource||'bing')!=='bing')return;s.wallpaper={date:day(),url:'/api/bing-wallpaper-image?date='+day(),title:d.title||'Bing daily image',source:'lifeos-proxy'};save('lifeos_settings',s);applyWallpaper(s.wallpaper)}catch(e){console.info('Bing wallpaper unavailable; using the calm fallback.',e)}}function init(){let s=settings();document.documentElement.dataset.theme=s.theme==='auto'?(matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'):s.theme;renderIcons();renderTaskbar();loadBingWallpaper();setInterval(clock,1000);clock();setTimeout(()=>window.applyLanguage?.(),0);setTimeout(()=>boot.remove(),650)} function desktopIconClick(id,e){if(innerWidth<650){openApp(id);return}selectedIcon=id;document.querySelectorAll('.app-icon').forEach(x=>x.classList.remove('selected'));e.currentTarget.classList.add('selected')}function desktopIconKey(id,e){if(e.key==='Enter'||e.key===' '){e.preventDefault();openApp(id)}}function renderIcons(){icons.innerHTML=Object.entries(APPS).map(([id,[g,n]])=>``).join('')}function taskClick(id){let w=windows[id];if(!w){openApp(id);return}if(w.classList.contains('min')){openApp(id);return}if(activeWindow===id){minWin(id);return}focus(w)}function renderTaskbar(){let ids=innerWidth<650?Object.keys(windows):['today','timeline','museum','insights','tasks','calendar','browser','terminal','database'];tasks.innerHTML=ids.map(id=>{let w=windows[id],min=w?.classList.contains('min'),fg=activeWindow===id&&!min,name=window.appName(id);return``}).join('')} -function clock(){let d=new Date();document.querySelector('#clock').textContent=d.toLocaleTimeString(LifeI18n.locale(), {hour:'2-digit',minute:'2-digit'});document.querySelector('#date').textContent=d.toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function toggleLauncher(){launcher.classList.toggle('hidden');calendar.classList.add('hidden');launcher.innerHTML=`
LIFE OS${LifeI18n.t(`shell.${new Date().getHours()<12?'morning':new Date().getHours()<18?'afternoon':'evening'}`)}
${Object.entries(APPS).map(([id,a])=>``).join('')}
${LifeI18n.t('shell.systemOnline',{version:window.LifeOSVersion||'1.14.0'})}
`}function toggleCalendar(){calendar.classList.toggle('hidden');launcher.classList.add('hidden');calendar.innerHTML=`
${LifeI18n.t('shell.systemDate')}

${fmtDate(day())}

${LifeI18n.t('shell.todaySummary',{memories:mems().length,preserved:museum().length})}

`}function hideContext(){document.querySelector('#life-context')?.remove()}function showContext(e,id){e.preventDefault();e.stopPropagation();hideContext();let labels={open:LifeI18n.t('shell.open'),min:LifeI18n.t('shell.minimize'),close:LifeI18n.t('shell.menuCloseWindow'),refresh:LifeI18n.t('shell.refresh'),new:LifeI18n.t('shell.newToday'),system:LifeI18n.t('shell.systemSettings')},actions=id?`${windows[id]?``:''}`:`
`;let m=document.createElement('div');m.id='life-context';m.style.cssText=`position:fixed;z-index:7000;left:${Math.min(e.clientX,innerWidth-190)}px;top:${Math.min(e.clientY,innerHeight-150)}px;width:180px;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)`;m.innerHTML=actions;m.querySelectorAll('button').forEach(b=>b.style.cssText='display:block;width:100%;border:0;background:transparent;color:var(--ink);text-align:left;border-radius:6px;padding:9px 10px;font:12px Manrope');m.querySelectorAll('button').forEach(b=>b.onmouseenter=()=>b.style.background='var(--panel2)');document.body.append(m)}document.addEventListener('contextmenu',e=>{if(e.target.closest('.desktop')&&!e.target.closest('.app-icon')&&!e.target.closest('.window'))showContext(e,null)});document.addEventListener('click',hideContext) +function clock(){let d=new Date();document.querySelector('#clock').textContent=d.toLocaleTimeString(LifeI18n.locale(), {hour:'2-digit',minute:'2-digit'});document.querySelector('#date').textContent=d.toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function toggleLauncher(){launcher.classList.toggle('hidden');calendar.classList.add('hidden');launcher.innerHTML=`
LIFE OS${LifeI18n.t(`shell.${new Date().getHours()<12?'morning':new Date().getHours()<18?'afternoon':'evening'}`)}
${Object.entries(APPS).map(([id,a])=>``).join('')}
${LifeI18n.t('shell.systemOnline',{version:window.LifeOSVersion||'1.14.1'})}
`}function toggleCalendar(){calendar.classList.toggle('hidden');launcher.classList.add('hidden');calendar.innerHTML=`
${LifeI18n.t('shell.systemDate')}

${fmtDate(day())}

${LifeI18n.t('shell.todaySummary',{memories:mems().length,preserved:museum().length})}

`}function hideContext(){document.querySelector('#life-context')?.remove()}function showContext(e,id){e.preventDefault();e.stopPropagation();hideContext();let labels={open:LifeI18n.t('shell.open'),min:LifeI18n.t('shell.minimize'),close:LifeI18n.t('shell.menuCloseWindow'),refresh:LifeI18n.t('shell.refresh'),new:LifeI18n.t('shell.newToday'),system:LifeI18n.t('shell.systemSettings')},actions=id?`${windows[id]?``:''}`:`
`;let m=document.createElement('div');m.id='life-context';m.style.cssText=`position:fixed;z-index:7000;left:${Math.min(e.clientX,innerWidth-190)}px;top:${Math.min(e.clientY,innerHeight-150)}px;width:180px;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)`;m.innerHTML=actions;m.querySelectorAll('button').forEach(b=>b.style.cssText='display:block;width:100%;border:0;background:transparent;color:var(--ink);text-align:left;border-radius:6px;padding:9px 10px;font:12px Manrope');m.querySelectorAll('button').forEach(b=>b.onmouseenter=()=>b.style.background='var(--panel2)');document.body.append(m)}document.addEventListener('contextmenu',e=>{if(e.target.closest('.desktop')&&!e.target.closest('.app-icon')&&!e.target.closest('.window'))showContext(e,null)});document.addEventListener('click',hideContext) function openApp(id){launcher.classList.add('hidden');let w=windows[id];if(w){w.classList.remove('min');focus(w);return}w=document.createElement('section');w.className='window';w.dataset.id=id;w.style.left=(innerWidth<650?0:90+Object.keys(windows).length*36)+'px';w.style.top=(innerWidth<650?0:72+Object.keys(windows).length*28)+'px';w.innerHTML=`
${APPS[id][0]}${appName(id)}
`;windows[id]=w;document.querySelector('#windows').append(w);let controls=w.querySelector('.controls'),bar=w.querySelector('.titlebar');controls.addEventListener('pointerdown',e=>{e.preventDefault();e.stopPropagation()});controls.addEventListener('click',e=>{let action=e.target.closest('button')?.dataset.windowAction;if(!action)return;e.preventDefault();e.stopPropagation();if(action==='min')minWin(id);if(action==='max')maxWin(id);if(action==='close')closeWin(id)});bar.addEventListener('dblclick',e=>{if(!e.target.closest('.controls'))maxWin(id)});w.addEventListener('pointerdown',()=>focus(w));drag(w);renderApp(id);focus(w)}function focus(w){if(!w)return;activeWindow=w.dataset.id;w.style.zIndex=++z;document.querySelectorAll('.window').forEach(x=>x.classList.toggle('active-window',x===w));renderTaskbar()}function closeWin(id){windows[id]?.remove();delete windows[id];if(activeWindow===id){activeWindow=Object.keys(windows).find(k=>!windows[k].classList.contains('min'))||null;if(activeWindow)focus(windows[activeWindow])}renderTaskbar()}function minWin(id){let w=windows[id];if(!w)return;w.classList.add('min');if(activeWindow===id){activeWindow=Object.keys(windows).find(k=>k!==id&&!windows[k].classList.contains('min'))||null;if(activeWindow)focus(windows[activeWindow])}renderTaskbar();toast(i18n('MINIMIZED — TAP THE APP ICON TO RESTORE'))}function maxWin(id){let w=windows[id];if(!w)return;w.classList.toggle('max');focus(w)}function snapWindow(w){if(innerWidth<650||w.classList.contains('max'))return;let r=w.getBoundingClientRect(),edge=22;if(r.topinnerWidth-edge){w.style.left='calc(50vw + 9px)';w.style.top='18px';w.style.width='calc(50vw - 27px)';w.style.height='calc(100vh - 98px)'}}function drag(w){let bar=w.querySelector('.titlebar'),x,y,l,t;bar.onpointerdown=e=>{if(e.target.closest('.controls')||innerWidth<650)return;if(w.classList.contains('max'))w.classList.remove('max');x=e.clientX;y=e.clientY;l=w.offsetLeft;t=w.offsetTop;bar.setPointerCapture(e.pointerId);bar.onpointermove=e=>{w.style.left=Math.max(0,l+e.clientX-x)+'px';w.style.top=Math.max(0,t+e.clientY-y)+'px'};bar.onpointerup=()=>{bar.onpointermove=null;snapWindow(w)}}}document.addEventListener('keydown',e=>{if(e.altKey&&e.key==='F4'&&activeWindow){e.preventDefault();closeWin(activeWindow)}if(e.key==='Escape'&&activeWindow&&!document.querySelector('.modal')&&!document.querySelector('#lifeos-search')){let w=windows[activeWindow];if(w?.classList.contains('max')){e.preventDefault();maxWin(activeWindow)}}}) function renderApp(id){let c=document.querySelector('#content-'+id);if(!c)return;if(id==='today')window.today(c);if(id==='timeline')window.timeline(c);if(id==='museum')window.museumApp(c);if(id==='insights')window.insights(c);if(id==='profile')window.profileApp(c);if(id==='tasks')window.tasksApp(c);if(id==='calendar')window.calendarApp(c);if(id==='system')window.systemApp(c);if(id==='browser')window.browserApp(c);if(id==='terminal')window.terminalApp(c);if(id==='database')window.databaseApp(c);setTimeout(()=>window.applyLanguage?.(),0)}function renderAll(){Object.keys(windows).forEach(renderApp);renderTaskbar();window.renderDesktopGoalCountdowns?.();setTimeout(()=>window.applyLanguage?.(),0)}document.addEventListener('click',e=>{if(e.target.closest('#content-today .chip,#content-today .energy button'))setTimeout(()=>window.applyLanguage?.(),0)}) let todayDraft=null,todayTitleDraft=null,todayTargetDate=null;function today(c){let m=mems().find(x=>x.date===day());if(todayDraft===null){selectedMood=m?.mood||'cloudy';selectedEnergy=m?.energy||3}let f=m||filmFor(selectedMood,selectedEnergy),text=todayDraft===null?(m?.text||''):todayDraft;c.innerHTML=`
${new Date().toLocaleDateString('en-US',{weekday:'long'}).toUpperCase()}

${fmtDate(day()).replace(/^[^,]+, /,'')}

How was your day?

`}function filmFor(m,e){const dict=LifeI18n.dictionaries[lang()==='zh'?'zh':'en'],seed=[...`${day()}|${m}|${e}`].reduce((sum,char,index)=>sum+char.charCodeAt(0)*(index+1),0),n=seed%dict.prompt.films.length;return{filmTitle:dict.prompt.films[n].title,filmSubtitle:dict.prompt.films[n].subtitle,filmLine:dict.prompt.lines[(n*3+e)%dict.prompt.lines.length]}}function rerenderToday(){let old=document.querySelector('#entry');todayDraft=old?old.value:todayDraft||'';window.today(document.querySelector('#content-today'));let entry=document.querySelector('#entry');entry?.focus();entry?.setSelectionRange(entry.value.length,entry.value.length)}function setMood(x){selectedMood=x;rerenderToday()}function setEnergy(x){selectedEnergy=x;rerenderToday()}function archiveToday(){let text=document.querySelector('#entry').value.trim(),a=mems(),old=a.find(x=>x.date===day()),f=old||filmFor(selectedMood,selectedEnergy),now=new Date().toISOString(),m={id:old?.id||'memory_'+crypto.randomUUID(),date:day(),text,mood:selectedMood,energy:selectedEnergy,filmTitle:f.filmTitle,filmSubtitle:f.filmSubtitle,filmLine:f.filmLine,createdAt:old?.createdAt||now,updatedAt:now};let status=document.querySelector('#saveStatus');status.textContent='ARCHIVING MEMORY...';setTimeout(()=>{save('lifeos_memories',[...a.filter(x=>x.date!==day()),m].sort((x,y)=>y.date.localeCompare(x.date)));todayDraft=null;status.textContent='MEMORY ARCHIVED';renderAll()},360)} @@ -23,6 +23,6 @@ function museumApp(c){let a=museum(),ms=mems();c.innerHTML=`
m.date===d.toISOString().slice(0,10))){s++;d.setDate(d.getDate()-1)}return s}function longest(a){let ds=a.map(x=>x.date).sort(),best=0,run=0,last='';ds.forEach(d=>{run=last&&((new Date(d)-new Date(last))/864e5===1)?run+1:1;best=Math.max(best,run);last=d});return best}function insights(c){let a=mems(),last=a.filter(m=>(Date.now()-new Date(m.date+'T12:00:00'))<2592e6),cnt={clear:0,light:0,cloudy:0,rain:0,storm:0};last.forEach(x=>cnt[x.mood]++);let avg=a.length?(a.reduce((s,x)=>s+x.energy,0)/a.length).toFixed(1):'—',days={};a.forEach(x=>{let d=new Date(x.date+'T12:00:00').toLocaleDateString('en-US',{weekday:'long'});days[d]=(days[d]||0)+1});let active=Object.entries(days).sort((x,y)=>y[1]-x[1])[0]?.[0]||'—';c.innerHTML=`
LAST 30 DAYS

Insights, quietly.

WEATHER
${Object.entries(cnt).map(([k,v])=>`
${cap(k)} ${v} DAYS
`).join('')}
AVERAGE ENERGY
${avg} / 5
${a.slice(0,10).reverse().map(x=>``).join('')||'No entries yet'}
MOST ACTIVE DAY
${active}
CURRENT STREAK
${streak(a)} DAYS
Longest streak: ${longest(a)} days
`} function profileApp(c){let p=profile(),a=mems(),common=Object.entries(a.reduce((o,m)=>(o[m.mood]=(o[m.mood]||0)+1,o),{})).sort((x,y)=>y[1]-x[1])[0]?.[0]||'—',avg=a.length?(a.reduce((x,m)=>x+m.energy,0)/a.length).toFixed(1):'—',started=Math.max(1,Math.floor((Date.now()-new Date(p.startDate))/864e5));c.innerHTML=`
SYSTEM PROFILE

${p.name?esc(p.name)+' · ':''}LIFE OS ONLINE

${started}DAYS ONLINE
${a.length}MEMORIES
${museum().length}MUSEUM ITEMS
${streak(a)}CURRENT STREAK
${longest(a)}LONGEST STREAK
${cap(common)}COMMON WEATHER
PERSONAL SYSTEM
AVERAGE ENERGY ${avg} / 5
`}function saveProfile(){save('lifeos_profile',{name:pname.value,birthday:pbday.value,startDate:pstart.value||day(),motto:pmotto.value});renderAll();toast('PROFILE SAVED')} function today(c){let m=mems().find(x=>x.date===day());if(todayDraft===null){selectedMood=m?.mood||'cloudy';selectedEnergy=m?.energy||3}let f=m||filmFor(selectedMood,selectedEnergy),text=todayDraft===null?(m?.text||''):todayDraft,cWeek=new Date().toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',{weekday:'long'}).toUpperCase();c.innerHTML=`
${cWeek}

${fmtDate(day()).replace(/^[^,]+, /,'')}

${i18n('How was your day?')}

`}function rerenderToday(){let old=document.querySelector('#entry');todayDraft=old?old.value:todayDraft||'';window.today(document.querySelector('#content-today'));let entry=document.querySelector('#entry');entry?.focus();entry?.setSelectionRange(entry.value.length,entry.value.length)} -function systemApp(c){let s=settings(),bytes=new Blob([JSON.stringify({mems:mems(),museum:museum(),profile:profile()})]).size;let rec=s.lastBackup&&(Date.now()-new Date(s.lastBackup)>12096e5);c.innerHTML=`
SYSTEM SETTINGS

System, local and yours.

APPEARANCE
${['dark','light','auto'].map(x=>``).join('')}
LANGUAGE
DATA
STORAGE

Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB

DATA STORAGE

Your data is stored locally in this browser.
Create regular backups to prevent data loss.

${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'
BACKUP RECOMMENDED':''}

VERSION

Life OS
Version ${window.LifeOSVersion||'1.14.0'} · Schema v2

`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:window.LifeOSVersion||'1.14.0',schemaVersion:1,exportedAt:new Date().toISOString(),profile:profile(),memories:mems(),museum:museum(),settings:settings()};let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));a.download='life-os-backup-'+day()+'.json';a.click();let s=settings();s.lastBackup=new Date().toISOString();save('lifeos_settings',s);renderAll();toast('BACKUP CREATED')}function readImport(el){let f=el.files[0];if(!f)return;let r=new FileReader;r.onload=()=>{try{let d=JSON.parse(r.result);if(d.app!=='Life OS'||![1,2].includes(d.schemaVersion)||!Array.isArray(d.memories)||!Array.isArray(d.museum))throw 0;importData=d;importPreview()}catch{toast('INVALID BACKUP FILE')}};r.readAsText(f)}function importPreview(){let d=importData;modal.innerHTML=``}function replaceConfirm(){modal.innerHTML=``}function applyImport(mode){let d=importData;if(mode==='replace'){save('lifeos_memories',d.memories);save('lifeos_museum',d.museum);save('lifeos_profile',d.profile||{});save('lifeos_settings',d.settings||{})}else{let a=mems(), ids=new Set(a.map(x=>x.id)),dates=new Set(a.map(x=>x.date));save('lifeos_memories',[...a,...d.memories.filter(x=>!ids.has(x.id)&&!dates.has(x.date))]);let mu=museum(),mi=new Set(mu.map(x=>x.id));save('lifeos_museum',[...mu,...d.museum.filter(x=>!mi.has(x.id))])}modal.innerHTML='';toast('RESTORING SYSTEM...');setTimeout(()=>{renderAll();toast('SYSTEM RESTORED')},500)}function resetConfirm(){modal.innerHTML=``}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)} +function systemApp(c){let s=settings(),bytes=new Blob([JSON.stringify({mems:mems(),museum:museum(),profile:profile()})]).size;let rec=s.lastBackup&&(Date.now()-new Date(s.lastBackup)>12096e5);c.innerHTML=`
SYSTEM SETTINGS

System, local and yours.

APPEARANCE
${['dark','light','auto'].map(x=>``).join('')}
LANGUAGE
DATA
STORAGE

Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB

DATA STORAGE

Your data is stored locally in this browser.
Create regular backups to prevent data loss.

${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'
BACKUP RECOMMENDED':''}

VERSION

Life OS
Version ${window.LifeOSVersion||'1.14.1'} · Schema v2

`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:window.LifeOSVersion||'1.14.1',schemaVersion:1,exportedAt:new Date().toISOString(),profile:profile(),memories:mems(),museum:museum(),settings:settings()};let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));a.download='life-os-backup-'+day()+'.json';a.click();let s=settings();s.lastBackup=new Date().toISOString();save('lifeos_settings',s);renderAll();toast('BACKUP CREATED')}function readImport(el){let f=el.files[0];if(!f)return;let r=new FileReader;r.onload=()=>{try{let d=JSON.parse(r.result);if(d.app!=='Life OS'||![1,2].includes(d.schemaVersion)||!Array.isArray(d.memories)||!Array.isArray(d.museum))throw 0;importData=d;importPreview()}catch{toast('INVALID BACKUP FILE')}};r.readAsText(f)}function importPreview(){let d=importData;modal.innerHTML=``}function replaceConfirm(){modal.innerHTML=``}function applyImport(mode){let d=importData;if(mode==='replace'){save('lifeos_memories',d.memories);save('lifeos_museum',d.museum);save('lifeos_profile',d.profile||{});save('lifeos_settings',d.settings||{})}else{let a=mems(), ids=new Set(a.map(x=>x.id)),dates=new Set(a.map(x=>x.date));save('lifeos_memories',[...a,...d.memories.filter(x=>!ids.has(x.id)&&!dates.has(x.date))]);let mu=museum(),mi=new Set(mu.map(x=>x.id));save('lifeos_museum',[...mu,...d.museum.filter(x=>!mi.has(x.id))])}modal.innerHTML='';toast('RESTORING SYSTEM...');setTimeout(()=>{renderAll();toast('SYSTEM RESTORED')},500)}function resetConfirm(){modal.innerHTML=``}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)} window.renderIcons=renderIcons;window.renderAll=renderAll;window.toast=toast; - + diff --git a/public/locales/en.json b/public/locales/en.json index d911b26..9e7f0d6 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -15,6 +15,16 @@ "db": { "title": "Database Manager", "loginBody": "Inspect and edit the records stored for your current account.", + "setupTitle": "Set your database password", + "setupBody": "Create a password for this account. The former shared password has been retired.", + "checking": "Checking database access…", + "setPassword": "Set password and enter", + "currentPassword": "Current password", + "newPassword": "New password (at least 12 characters)", + "confirmPassword": "Confirm new password", + "passwordMismatch": "The two passwords do not match.", + "changePassword": "Change password", + "passwordChanged": "Password updated. Previous database sessions were signed out.", "server": "Server", "username": "Username", "password": "Password", diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index edf5236..ffd385e 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -15,6 +15,16 @@ "db": { "title": "数据库管理器", "loginBody": "查看和编辑当前账号保存在云端的记录。", + "setupTitle": "设置数据库管理密码", + "setupBody": "为当前账号创建专属密码。原来的共用密码已停用。", + "checking": "正在检查数据库访问状态…", + "setPassword": "设置密码并进入", + "currentPassword": "当前密码", + "newPassword": "新密码(至少 12 个字符)", + "confirmPassword": "确认新密码", + "passwordMismatch": "两次输入的密码不一致。", + "changePassword": "修改密码", + "passwordChanged": "密码已更新,旧的数据库会话已退出。", "server": "服务器", "username": "用户名", "password": "密码", diff --git a/public/service-worker.js b/public/service-worker.js index 7600acd..6b0ee69 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE = 'life-os-shell-v1.14.0'; +const CACHE = 'life-os-shell-v1.14.1'; const SHELL = [ '/', '/index.html', '/manifest.webmanifest', '/life-os-icon.svg' ]; diff --git a/public/terminal-app.js b/public/terminal-app.js index 6531c2e..9ea97e2 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.14.0'}` }; } + if (kind === 'version') { if (verb) throw new Error('usage'); return { message: `Life OS v${window.LifeOSVersion || '1.14.1'}` }; } 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.14.0' }), kind: 'system' }); welcomed = true; } + if (!welcomed) { lines.unshift({ value: t('welcome', { version: window.LifeOSVersion || '1.14.1' }), 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 38039e5..ef11bcd 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -8,7 +8,7 @@ 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, 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 release = "1.14.1"; 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}"`); diff --git a/scripts/test-cloud.mjs b/scripts/test-cloud.mjs index 4770f2b..f2dee7c 100644 --- a/scripts/test-cloud.mjs +++ b/scripts/test-cloud.mjs @@ -9,6 +9,7 @@ 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")); +sqlite.exec(readFileSync(new URL("../drizzle/0003_lifeos_admin_credentials.sql", import.meta.url), "utf8")); const db = { prepare(sql) { return { @@ -37,7 +38,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, LIFEOS_DB_ADMIN_PASSWORD: "local test password with 28 chars" }); + }), { DB: db }); return { status: response.status, body: await response.json() }; } @@ -104,6 +105,15 @@ assert.deepEqual(Array.from(pending, item => item.kind), ["tasks"], "editing a t 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/config")).body.configured, false); +assert.equal((await call("/api/db-admin/login", "POST", { username: "lifeos-admin", password: "local test password with 28 chars", acknowledged: true })).status, 409); +const setup = await call("/api/db-admin/setup", "POST", { + username: "lifeos-admin", password: "local test password with 28 chars", acknowledged: true +}); +assert.equal(setup.status, 200); +assert.equal((await call("/api/db-admin/config")).body.configured, true); +assert.equal((await call("/api/db-admin/setup", "POST", { username: "lifeos-admin", password: "replacement password 123", acknowledged: true })).status, 409); +assert.equal((await call("/api/db-admin/tables/tasks/rows", "GET", undefined, { ...headers, "x-lifeos-admin-session": setup.body.token })).status, 200); 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 @@ -115,12 +125,22 @@ 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"); +assert.equal((await call("/api/db-admin/config", "GET", undefined, { "oai-authenticated-user-id": "bob" })).body.configured, false); +assert.equal((await call("/api/db-admin/setup", "POST", { username: "lifeos-admin", password: "bob account password 67890", acknowledged: true }, { "oai-authenticated-user-id": "bob" })).status, 200); +assert.equal((await call("/api/db-admin/login", "POST", { username: "lifeos-admin", password: "bob account password 67890", acknowledged: true })).status, 401, "another user's password cannot unlock Alice"); 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/password", "POST", { currentPassword: "wrong password 123", newPassword: "new secure password 12345" }, auth)).status, 401); +const rotated = await call("/api/db-admin/password", "POST", { + currentPassword: "local test password with 28 chars", newPassword: "new secure password 12345" +}, auth); +assert.equal(rotated.status, 200); assert.equal((await call("/api/db-admin/tables/tasks/rows", "GET", undefined, auth)).status, 401); +assert.equal((await call("/api/db-admin/login", "POST", { username: "lifeos-admin", password: "local test password with 28 chars", acknowledged: true })).status, 401); +assert.equal((await call("/api/db-admin/login", "POST", { username: "lifeos-admin", password: "new secure password 12345", acknowledged: true })).status, 200); +assert.equal((await call("/api/db-admin/logout", "POST", {}, { ...headers, "x-lifeos-admin-session": rotated.body.token })).status, 200); console.log("Life OS record migration, sync conflict, tags, and user isolation valid"); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 328d318..e197a90 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -58,12 +58,15 @@ for (const script of [assets.i18nJs, assets.dataActionsJs, assets.terminalAppJs, const adminNode = { innerHTML: "", querySelector: () => ({ addEventListener() {} }) }; const adminContext = vm.createContext({ document: { createElement: () => ({ textContent: "" }), head: { append() {} }, querySelector: () => adminNode }, - LifeI18n: { t: key => key }, window: {}, Date + LifeI18n: { t: key => key }, window: {}, Date, + fetch: async () => ({ ok: true, status: 200, json: async () => ({ configured: false }) }) }); vm.runInContext(assets.dbAdminJs, adminContext); adminContext.window.databaseApp(adminNode); +await new Promise(resolve => setTimeout(resolve, 0)); assert.match(adminNode.innerHTML, /lifeos-admin/); assert.match(adminNode.innerHTML, /type="password"/); +assert.match(adminNode.innerHTML, /confirmPassword/); assert.match(adminNode.innerHTML, /name="acknowledged" required/); const browserNodes = Object.fromEntries(['.browser-toolbar', '.browser-address', 'iframe', '[data-action="back"]', '[data-action="forward"]', '.browser-external', '.browser-hint-link'].map(key => [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]; } }; @@ -183,7 +186,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.14.0'); +assert.equal(command('version').message, 'Life OS v1.14.1'); 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 index 6026cb6..bf341cc 100644 --- a/worker/db-admin.js +++ b/worker/db-admin.js @@ -7,15 +7,24 @@ const TABLES = { }; const META = new Set(["profile", "settings", "system"]); const DURATION = 20 * 60 * 1000; +const ITERATIONS = 210_000; 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)]); +const secureEqual = (first, second) => { + if (typeof first !== "string" || typeof second !== "string" || first.length !== second.length) return false; let difference = 0; for (let i = 0; i < first.length; i++) difference |= first.charCodeAt(i) ^ second.charCodeAt(i); return difference === 0; }; +const hex = bytes => [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join(""); +const unhex = value => Uint8Array.from(value.match(/../g).map(pair => parseInt(pair, 16))); +async function derive(password, salt, iterations = ITERATIONS) { + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]); + return hex(new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: unhex(salt), iterations }, key, 256))); +} +const validPassword = value => typeof value === "string" && value.length >= 12 && value.length <= 256; +const salt = () => hex(crypto.getRandomValues(new Uint8Array(16))); const token = () => { const bytes = crypto.getRandomValues(new Uint8Array(32)); return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); @@ -23,34 +32,65 @@ const token = () => { 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(); + const record = await db.prepare("SELECT s.expires_at FROM lifeos_admin_sessions s JOIN lifeos_admin_credentials c ON c.user_id = s.user_id WHERE s.token_hash = ? AND s.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 readInput(request) { + const text = await request.text(); + if (text.length > 2048) return null; + try { return JSON.parse(text); } catch { return null; } +} +async function newSession(db, userId) { + const raw = token(), expiresAt = Date.now() + DURATION; + await 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 setup(request, db, userId) { + const input = await readInput(request); + if (input?.acknowledged !== true || input.username !== "lifeos-admin" || !validPassword(input.password)) return body({ error: "Invalid setup" }, 400); + const newSalt = salt(), passwordHash = await derive(input.password, newSalt); + const result = await db.prepare("INSERT INTO lifeos_admin_credentials(user_id, salt, password_hash, iterations, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING") + .bind(userId, newSalt, passwordHash, ITERATIONS, new Date().toISOString()).run(); + if (!result.meta.changes) return body({ error: "Password already configured" }, 409); + await db.prepare("DELETE FROM lifeos_admin_sessions WHERE user_id = ?").bind(userId).run(); + await db.prepare("DELETE FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).run(); + return newSession(db, userId); +} 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 credential = await env.DB.prepare("SELECT salt, password_hash, iterations FROM lifeos_admin_credentials WHERE user_id = ?").bind(userId).first(); + if (!credential) return body({ error: "Set your password first" }, 409); 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); } + const input = await readInput(request); 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); + const matched = input.username === "lifeos-admin" && validPassword(input.password) && + secureEqual(await derive(input.password, credential.salt, credential.iterations), credential.password_hash); 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 }); + return newSession(env.DB, userId); +} +async function changePassword(request, db, userId) { + const input = await readInput(request); + if (!validPassword(input?.currentPassword) || !validPassword(input?.newPassword)) return body({ error: "Invalid password" }, 400); + const credential = await db.prepare("SELECT salt, password_hash, iterations FROM lifeos_admin_credentials WHERE user_id = ?").bind(userId).first(); + if (!credential || !secureEqual(await derive(input.currentPassword, credential.salt, credential.iterations), credential.password_hash)) + return body({ error: "Current password is incorrect" }, 401); + if (input.newPassword === input.currentPassword) return body({ error: "Choose a different password" }, 400); + const newSalt = salt(), passwordHash = await derive(input.newPassword, newSalt); + await db.batch([ + db.prepare("UPDATE lifeos_admin_credentials SET salt = ?, password_hash = ?, iterations = ?, updated_at = ? WHERE user_id = ?") + .bind(newSalt, passwordHash, ITERATIONS, new Date().toISOString(), userId), + db.prepare("DELETE FROM lifeos_admin_sessions WHERE user_id = ?").bind(userId) + ]); + return newSession(db, userId); } async function list(db, userId, kind, page) { const table = TABLES[kind], offset = page * 50; @@ -92,8 +132,14 @@ export async function dbAdminApi(request, env, userId, pathname) { if (!originAllowed(request)) return body({ error: "Origin rejected" }, 403); const segments = pathname.slice("/api/db-admin".length).split("/").filter(Boolean); try { + if (segments[0] === "config" && segments.length === 1 && request.method === "GET") { + const credential = await env.DB.prepare("SELECT user_id FROM lifeos_admin_credentials WHERE user_id = ?").bind(userId).first(); + return body({ configured: !!credential }); + } + if (segments[0] === "setup" && segments.length === 1 && request.method === "POST") return setup(request, env.DB, userId); 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] === "password" && segments.length === 1 && request.method === "POST") return changePassword(request, env.DB, userId); 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();