Revert "Add client-side note encryption"

This reverts commit 5cf5a45682.
This commit is contained in:
eezd committed 2026-10-17 14:56:23 +08:00
1 parent 5cf5a45682
commit b26c5aa693
20 files changed
+39 -899

No files matched your search

+2 -12
View File
@@ -3,8 +3,7 @@ import { EditorState, type StateEffect, type Text } from '@codemirror/state'
import type { EditorView } from '@codemirror/view' import type { EditorView } from '@codemirror/view'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { createEditorView } from '../editor' import { createEditorView } from '../editor'
import { keyState } from './encryption' import { activeTab, attachView, editorStateOf, noteRefOf, revealPending, storeViewState, ws } from './workspace'
import { activeTab, attachView, editorStateOf, noteRefOf, revealPending, storeViewState, unlockNotes, ws } from './workspace'
const host = ref<HTMLElement>() const host = ref<HTMLElement>()
const tab = computed(activeTab) const tab = computed(activeTab)
@@ -29,8 +28,6 @@ function show() {
scrollPositions.set(shown, { doc: view.state.doc, snapshot: view.scrollSnapshot(), top: view.scrollDOM.scrollTop }) scrollPositions.set(shown, { doc: view.state.doc, snapshot: view.scrollSnapshot(), top: view.scrollDOM.scrollTop })
} }
if (!ref || !state) { if (!ref || !state) {
// 锁定后视图中不能留下已解密的内容
if (shown) view.setState(EditorState.create())
shown = undefined shown = undefined
return return
} }
@@ -63,17 +60,10 @@ watch(() => [ws.active, tab.value?.status], show, { flush: 'post' })
<template> <template>
<div class="rm-editor-pane"> <div class="rm-editor-pane">
<div v-show="tab && !['loading', 'error', 'locked'].includes(tab.status)" ref="host" class="rm-editor-host" /> <div v-show="tab && tab.status !== 'loading' && tab.status !== 'error'" ref="host" class="rm-editor-host" />
<div v-if="!tab" class="rm-empty"> <div v-if="!tab" class="rm-empty">
<p>选择或新建一篇笔记</p> <p>选择或新建一篇笔记</p>
</div> </div>
<div v-else-if="tab.status === 'error'" class="rm-empty"><p>笔记打开失败</p></div> <div v-else-if="tab.status === 'error'" class="rm-empty"><p>笔记打开失败</p></div>
<div v-else-if="tab.status === 'locked'" class="rm-empty">
<template v-if="keyState.supported">
<p>这是一篇加密笔记</p>
<button class="rm-btn rm-btn-primary" @click="unlockNotes()">解锁</button>
</template>
<p v-else class="rm-locked-hint">这是一篇加密笔记。当前连接不是 HTTPS,浏览器不允许网页使用加密功能,无法查看。请通过 HTTPS 访问。</p>
</div>
</div> </div>
</template> </template>
-1
View File
@@ -14,7 +14,6 @@ const paths = {
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5', restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z', edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35', search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
lock: 'M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4',
command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z', command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
} }
defineProps<{ name: keyof typeof paths; size?: number }>() defineProps<{ name: keyof typeof paths; size?: number }>()
-86
View File
@@ -1,86 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ApiError } from './api'
import { WrongPasswordError } from './crypto'
import { changePassword, closeKeyDialog, keyState, setupKeyring, unlock } from './encryption'
import Icon from './Icon.vue'
import { toast } from './workspace'
// 与登录密码的最短长度一致(internal/auth MinPasswordLen)
const MIN_LENGTH = 8
const mode = computed(() => keyState.dialog!)
const title = computed(() => ({ unlock: '解锁加密笔记', setup: '设置加密密码', change: '修改加密密码' })[mode.value])
const current = ref('')
const password = ref('')
const confirm = ref('')
const error = ref('')
const busy = ref(false)
const form = ref<HTMLFormElement>()
// autofocus 对动态插入的元素不一定生效
onMounted(() => form.value?.querySelector('input')?.focus())
async function submit() {
error.value = ''
if (mode.value !== 'unlock') {
if ([...password.value].length < MIN_LENGTH) return void (error.value = `密码至少 ${MIN_LENGTH} 个字符`)
if (password.value !== confirm.value) return void (error.value = '两次输入的密码不一致')
}
busy.value = true
try {
if (mode.value === 'unlock') await unlock(password.value)
else if (mode.value === 'setup') await setupKeyring(password.value)
else {
await changePassword(current.value, password.value)
toast('加密密码已修改')
}
closeKeyDialog(true)
} catch (err) {
if (err instanceof WrongPasswordError) error.value = mode.value === 'change' ? '当前密码错误' : '密码错误'
else if (err instanceof ApiError && err.status === 409) error.value = '加密密码已在其他地方设置,请关闭后重新解锁'
else if (err instanceof ApiError && err.status === 412) error.value = '加密密码刚在其他地方被修改,请关闭后重试'
else error.value = err instanceof Error ? err.message : String(err)
} finally {
busy.value = false
}
}
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="closeKeyDialog(false)">
<div class="rm-dialog rm-key-dialog" role="dialog" :aria-label="title">
<div class="rm-dialog-head">
<h2>{{ title }}</h2>
<button class="rm-icon-btn" title="关闭" @click="closeKeyDialog(false)"><Icon name="close" /></button>
</div>
<form ref="form" class="rm-auth-card rm-key-form" @submit.prevent="submit">
<p v-if="mode === 'setup'" class="rm-auth-hint">
加密密码与登录密码分开,所有加密笔记共用。<strong>忘记加密密码将无法恢复任何加密笔记</strong>,请妥善保管。
</p>
<p v-else-if="mode === 'change'" class="rm-auth-hint">修改后已加密的笔记无需重新加密,旧密码立即失效。</p>
<label v-if="mode === 'change'">
<span>当前密码</span>
<input v-model="current" type="password" autocomplete="current-password" required />
</label>
<label>
<span>{{ mode === 'change' ? '新密码' : '加密密码' }}</span>
<input
v-model="password"
type="password"
:autocomplete="mode === 'unlock' ? 'current-password' : 'new-password'"
required
/>
</label>
<label v-if="mode !== 'unlock'">
<span>确认密码</span>
<input v-model="confirm" type="password" autocomplete="new-password" required />
</label>
<p v-if="error" class="rm-auth-error">{{ error }}</p>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">
{{ busy ? '处理中…' : mode === 'unlock' ? '解锁' : '确定' }}
</button>
</form>
</div>
</div>
</template>
+5 -5
View File
@@ -3,7 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { availableCommands, formatKeys } from './commands' import { availableCommands, formatKeys } from './commands'
import { fuzzyMatch } from './fuzzy' import { fuzzyMatch } from './fuzzy'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { dirname, ENCRYPTED_EXT, isEncrypted, NOTE_EXT, noteTitle } from './paths' import { dirname, NOTE_EXT, noteTitle } from './paths'
import { createNoteAt, openAt, ws } from './workspace' import { createNoteAt, openAt, ws } from './workspace'
const MAX_ITEMS = 50 const MAX_ITEMS = 50
@@ -12,7 +12,7 @@ const RECENT_COMMANDS_MAX = 10
interface Item { interface Item {
key: string key: string
icon: 'note' | 'lock' | 'noteAdd' | 'command' icon: 'note' | 'noteAdd' | 'command'
title: string title: string
// 笔记为所在目录,命令为快捷键 // 笔记为所在目录,命令为快捷键
detail: string detail: string
@@ -59,7 +59,7 @@ function noteItems(q: string): Item[] {
const offset = dir ? dir.length + 1 : 0 const offset = dir ? dir.length + 1 : 0
return { return {
key: path, key: path,
icon: isEncrypted(path) ? 'lock' : 'note', icon: 'note',
title: noteTitle(path), title: noteTitle(path),
detail: dir, detail: dir,
titleHits: new Set(positions.filter((p) => p >= offset).map((p) => p - offset)), titleHits: new Set(positions.filter((p) => p >= offset).map((p) => p - offset)),
@@ -80,7 +80,7 @@ function noteItems(q: string): Item[] {
} }
const scored: { path: string; score: number; positions: number[] }[] = [] const scored: { path: string; score: number; positions: number[] }[] = []
for (const e of notes) { for (const e of notes) {
const target = e.path.replace(/\.md(\.enc)?$/, '') const target = e.path.slice(0, -NOTE_EXT.length)
const m = fuzzyMatch(q, target, target.lastIndexOf('/') + 1) const m = fuzzyMatch(q, target, target.lastIndexOf('/') + 1)
if (m) scored.push({ path: e.path, ...m }) if (m) scored.push({ path: e.path, ...m })
} }
@@ -93,7 +93,7 @@ function noteItems(q: string): Item[] {
const path = name + NOTE_EXT const path = name + NOTE_EXT
if ( if (
name.split('/').every((seg) => seg.trim() && !seg.startsWith('.')) && name.split('/').every((seg) => seg.trim() && !seg.startsWith('.')) &&
!ws.entries.some((e) => [path, name + ENCRYPTED_EXT].some((p) => e.path.toLowerCase() === p.toLowerCase())) !ws.entries.some((e) => e.path.toLowerCase() === path.toLowerCase())
) { ) {
items.push({ items.push({
key: '\0create', key: '\0create',
+1 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { isEncrypted, noteTitle } from './paths' import { noteTitle } from './paths'
import { closeTab, ws } from './workspace' import { closeTab, ws } from './workspace'
</script> </script>
@@ -17,7 +17,6 @@ import { closeTab, ws } from './workspace'
@click="ws.active = tab.path" @click="ws.active = tab.path"
@auxclick.prevent="$event.button === 1 && closeTab(tab.path)" @auxclick.prevent="$event.button === 1 && closeTab(tab.path)"
> >
<Icon v-if="isEncrypted(tab.path)" name="lock" :size="12" class="rm-lock-icon" />
<span class="rm-tab-title">{{ noteTitle(tab.path) }}</span> <span class="rm-tab-title">{{ noteTitle(tab.path) }}</span>
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)"> <button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)">
<span class="rm-dirty-dot" /> <span class="rm-dirty-dot" />
+1 -1
View File
@@ -48,7 +48,7 @@ onMounted(load)
<li v-for="item in items ?? []" :key="item.id"> <li v-for="item in items ?? []" :key="item.id">
<Icon :name="item.dir ? 'folder' : 'note'" /> <Icon :name="item.dir ? 'folder' : 'note'" />
<div class="rm-trash-info"> <div class="rm-trash-info">
<span class="rm-trash-path">{{ item.path.replace(/\.md(\.enc)?$/, '') }}</span> <span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span> <span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
</div> </div>
<button class="rm-btn" @click="doRestore(item)">恢复</button> <button class="rm-btn" @click="doRestore(item)">恢复</button>
+3 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { basename, isEncrypted, joinPath, noteTitle } from './paths' import { basename, joinPath } from './paths'
import { expanded, type TreeNode } from './tree' import { expanded, type TreeNode } from './tree'
import { createFolder, createNote, move, openNote, remove, renameTarget, ws } from './workspace' import { createFolder, createNote, move, openNote, remove, renameTarget, ws } from './workspace'
@@ -26,7 +26,7 @@ watch(
isRenaming, isRenaming,
async (on) => { async (on) => {
if (!on) return if (!on) return
draft.value = props.node.dir ? props.node.name : noteTitle(props.node.name) draft.value = props.node.dir ? props.node.name : props.node.name.replace(/\.md$/, '')
await nextTick() await nextTick()
input.value?.focus() input.value?.focus()
input.value?.select() input.value?.select()
@@ -104,8 +104,7 @@ function onDrop(e: DragEvent) {
@keydown.esc.prevent="cancelRename" @keydown.esc.prevent="cancelRename"
@blur="commitRename" @blur="commitRename"
/> />
<span v-else class="rm-tree-name">{{ node.dir ? node.name : noteTitle(node.name) }}</span> <span v-else class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
<Icon v-if="!isRenaming && isEncrypted(node.path)" name="lock" :size="12" class="rm-lock-icon" />
<button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen = !menuOpen"> <button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen = !menuOpen">
<Icon name="more" /> <Icon name="more" />
</button> </button>
-3
View File
@@ -3,8 +3,6 @@ import { computed, onBeforeUnmount, onMounted } from 'vue'
import { api } from './api' import { api } from './api'
import { registerCommand } from './commands' import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue' import EditorPane from './EditorPane.vue'
import { keyState } from './encryption'
import KeyDialog from './KeyDialog.vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import NoteBanner from './NoteBanner.vue' import NoteBanner from './NoteBanner.vue'
import QuickSwitcher from './QuickSwitcher.vue' import QuickSwitcher from './QuickSwitcher.vue'
@@ -69,6 +67,5 @@ onBeforeUnmount(() => {
</main> </main>
<QuickSwitcher v-if="ws.switcher !== null" /> <QuickSwitcher v-if="ws.switcher !== null" />
<TrashDialog v-if="ws.trashOpen" @close="ws.trashOpen = false" /> <TrashDialog v-if="ws.trashOpen" @close="ws.trashOpen = false" />
<KeyDialog v-if="keyState.dialog" :key="keyState.dialog" />
</div> </div>
</template> </template>
-35
View File
@@ -96,15 +96,6 @@ export const api = {
return (await json<{ rev: string }>(res)).rev return (await json<{ rev: string }>(res)).rev
}, },
// 加密/取消加密:写入 path 并删除同名的另一种笔记 from,ifMatch 为 from 的版本
async convert(from: string, path: string, content: string, ifMatch: string): Promise<string> {
const res = request('PUT', `/api/v1/files/${encodePath(path)}?convertFrom=${encodeURIComponent(from)}`, {
body: content,
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'If-Match': `"${ifMatch}"` },
})
return (await json<{ rev: string }>(res)).rev
},
deleteFile: (path: string) => json<TrashItem>(request('DELETE', '/api/v1/files/' + encodePath(path))), deleteFile: (path: string) => json<TrashItem>(request('DELETE', '/api/v1/files/' + encodePath(path))),
move: (from: string, to: string) => request('POST', '/api/v1/move', { json: { from, to } }), move: (from: string, to: string) => request('POST', '/api/v1/move', { json: { from, to } }),
mkdir: (path: string) => request('POST', '/api/v1/folders', { json: { path } }), mkdir: (path: string) => request('POST', '/api/v1/folders', { json: { path } }),
@@ -115,32 +106,6 @@ export const api = {
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
}, },
// 读取 /api/v1/raw 地址的文件内容(加密图片需要取回后在浏览器解密)
async raw(url: string): Promise<Blob> {
return (await request('GET', url)).blob()
},
deleteAsset: (path: string) => request('DELETE', '/api/v1/assets/' + encodePath(path)),
// 未设置过加密密码时返回 null
async keyring(): Promise<{ text: string; rev: string } | null> {
try {
const res = await request('GET', '/api/v1/keyring')
return { text: await res.text(), rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null
throw err
}
},
// ifMatch 为空字符串表示首次创建
async putKeyring(text: string, ifMatch: string): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (ifMatch) headers['If-Match'] = `"${ifMatch}"`
else headers['If-None-Match'] = '*'
return (await json<{ rev: string }>(request('PUT', '/api/v1/keyring', { body: text, headers }))).rev
},
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items, trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)), restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`), purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
-4
View File
@@ -354,10 +354,6 @@ button {
} }
.rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; } .rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; }
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: 16px; } .rm-dialog-head h2 { flex: 1; margin: 0; font-size: 16px; }
.rm-key-dialog { max-width: 400px; }
.rm-locked-hint { max-width: 360px; padding: 0 24px; text-align: center; line-height: 1.6; }
.rm-key-form { max-width: none; box-sizing: border-box; padding: 4px 20px 20px; }
.rm-lock-icon { flex: none; color: var(--rm-text-muted); }
.rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; } .rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; }
.rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); } .rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); }
.rm-trash-list li:hover { background: var(--rm-hover); } .rm-trash-list li:hover { background: var(--rm-hover); }
-108
View File
@@ -1,108 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
createKeyring,
decryptAsset,
decryptNote,
encryptAsset,
encryptNote,
newNoteKey,
parseKeyring,
rewrapKeyring,
unlockKeyring,
WrongPasswordError,
} from './crypto'
// 测试中缩短 PBKDF2 迭代次数
const ITER = 1000
describe('密钥环', () => {
it('正确密码解锁后能解密之前加密的笔记,错误密码报 WrongPasswordError', async () => {
const { keyring, master } = await createKeyring('正确的密码', ITER)
const stored = parseKeyring(JSON.stringify(keyring))
const file = await encryptNote(await newNoteKey(master), '# 秘密\n内容')
const unlocked = await unlockKeyring(stored, '正确的密码')
expect((await decryptNote(unlocked, file)).text).toBe('# 秘密\n内容')
await expect(unlockKeyring(stored, '错误的密码')).rejects.toBeInstanceOf(WrongPasswordError)
})
it('主密钥不可导出', async () => {
const { keyring, master } = await createKeyring('password', ITER)
expect(master.extractable).toBe(false)
expect((await unlockKeyring(keyring, 'password')).extractable).toBe(false)
})
it('修改密码后旧笔记仍可解密,旧密码失效', async () => {
const { keyring, master } = await createKeyring('old-password', ITER)
const file = await encryptNote(await newNoteKey(master), '内容')
await expect(rewrapKeyring(keyring, '不对', 'new-password', ITER)).rejects.toBeInstanceOf(WrongPasswordError)
const next = await rewrapKeyring(keyring, 'old-password', 'new-password', ITER)
expect(next.kdf.salt).not.toBe(keyring.kdf.salt)
expect((await decryptNote(await unlockKeyring(next, 'new-password'), file)).text).toBe('内容')
await expect(unlockKeyring(next, 'old-password')).rejects.toBeInstanceOf(WrongPasswordError)
})
it('拒绝无法识别的格式', () => {
expect(() => parseKeyring('{"v":2}')).toThrow('密钥环格式无法识别')
expect(() => parseKeyring('null')).toThrow('密钥环格式无法识别')
})
})
describe('笔记', () => {
it('同一笔记密钥多次加密:每次 iv 不同,密钥信封不变', async () => {
const { master } = await createKeyring('password', ITER)
const nk = await newNoteKey(master)
const a = JSON.parse(await encryptNote(nk, 'x'))
const b = JSON.parse(await encryptNote(nk, 'x'))
expect(a.iv).not.toBe(b.iv)
expect(a.key).toEqual(b.key)
// 解密返回的密钥可继续用于保存
const { key } = await decryptNote(master, JSON.stringify(a))
expect((await decryptNote(master, await encryptNote(key, '新内容'))).text).toBe('新内容')
})
it('其他密钥环或被篡改的文件解密失败', async () => {
const { master } = await createKeyring('password', ITER)
const other = (await createKeyring('password', ITER)).master
const file = await encryptNote(await newNoteKey(master), '内容')
await expect(decryptNote(other, file)).rejects.toThrow('解密失败')
const env = JSON.parse(file)
env.data = env.data.slice(0, -4) + (env.data.endsWith('AAAA') ? 'BBBB' : 'AAAA')
await expect(decryptNote(master, JSON.stringify(env))).rejects.toThrow('解密失败')
await expect(decryptNote(master, '明文')).rejects.toThrow('加密笔记格式无效')
await expect(decryptNote(master, '{}')).rejects.toThrow('加密笔记格式无效')
})
it('空笔记与大笔记', async () => {
const { master } = await createKeyring('password', ITER)
const nk = await newNoteKey(master)
const big = '中文内容 🙂\n'.repeat(100_000)
for (const text of ['', big]) expect((await decryptNote(master, await encryptNote(nk, text))).text).toBe(text)
})
})
describe('图片', () => {
it('往返保留内容与类型', async () => {
const { master } = await createKeyring('password', ITER)
const bytes = Uint8Array.from({ length: 200_000 }, (_, i) => (i * 7919) % 256)
const file = await encryptAsset(master, bytes.buffer, 'image/png')
expect([...file.subarray(0, 4)]).toEqual([0x52, 0x4d, 0x45, 0x31])
const blob = await decryptAsset(master, file.buffer)
expect(blob.type).toBe('image/png')
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes)
})
it('错误密钥、篡改与非加密文件', async () => {
const { master } = await createKeyring('password', ITER)
const other = (await createKeyring('password', ITER)).master
const file = await encryptAsset(master, new Uint8Array([1, 2, 3]).buffer, 'image/gif')
await expect(decryptAsset(other, file.buffer)).rejects.toThrow('解密失败')
file[file.length - 1] ^= 1
await expect(decryptAsset(master, file.buffer)).rejects.toThrow('解密失败')
await expect(decryptAsset(master, new TextEncoder().encode('\x89PNG plain image data, not encrypted at all......').buffer)).rejects.toThrow('格式无效')
})
})
-200
View File
@@ -1,200 +0,0 @@
// 笔记加密的密码学部分(设计见 docs/architecture.md §6):
// 加密密码 →PBKDF2→ KEK,包裹随机主密钥,存于 .rawmark/keyring.json;
// 每篇笔记、每张图片各有一个随机 AES-256-GCM 数据密钥,由主密钥包裹后与密文存在一起。
// 全部使用 AES-GCM(含包裹),密码错误、文件被篡改都会在解密时失败而不是得到乱码
const PBKDF2_ITERATIONS = 600_000
const IV_BYTES = 12
// AES-256 原始密钥 32 字节 + GCM 认证标签 16 字节
const WRAPPED_KEY_BYTES = 48
// 加密图片的文件头 "RME1"
const ASSET_MAGIC = [0x52, 0x4d, 0x45, 0x31]
interface Wrapped {
iv: string
data: string
}
export interface Keyring {
v: 1
kdf: { name: 'PBKDF2'; hash: 'SHA-256'; iterations: number; salt: string }
// 被 KEK 包裹的主密钥
key: Wrapped
}
// 加密笔记(.md.enc)的文件内容
interface NoteEnvelope {
v: 1
// 被主密钥包裹的笔记密钥
key: Wrapped
iv: string
data: string
}
/** 已解开的笔记密钥;保留包裹后的形式,每次保存只需重新加密正文 */
export interface NoteKey {
key: CryptoKey
wrapped: Wrapped
}
export class WrongPasswordError extends Error {
constructor() {
super('加密密码错误')
}
}
const encoder = new TextEncoder()
const decoder = new TextDecoder()
function toBase64(bytes: Uint8Array): string {
let s = ''
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
return btoa(s)
}
function fromBase64(s: string): Uint8Array<ArrayBuffer> {
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
}
const randomBytes = (n: number) => crypto.getRandomValues(new Uint8Array(n))
const gcm = (iv: Uint8Array<ArrayBuffer>): AesGcmParams => ({ name: 'AES-GCM', iv })
const AES: AesKeyGenParams = { name: 'AES-GCM', length: 256 }
const MASTER_USAGES: KeyUsage[] = ['wrapKey', 'unwrapKey']
const DATA_USAGES: KeyUsage[] = ['encrypt', 'decrypt']
// GCM 认证失败在 WebCrypto 中表现为 OperationError
const isAuthFailure = (err: unknown) => err instanceof DOMException && err.name === 'OperationError'
async function wrap(key: CryptoKey, wrappingKey: CryptoKey): Promise<Wrapped> {
const iv = randomBytes(IV_BYTES)
const data = await crypto.subtle.wrapKey('raw', key, wrappingKey, gcm(iv))
return { iv: toBase64(iv), data: toBase64(new Uint8Array(data)) }
}
function unwrap(w: Wrapped, wrappingKey: CryptoKey, usages: KeyUsage[], extractable = false): Promise<CryptoKey> {
return crypto.subtle.unwrapKey('raw', fromBase64(w.data), wrappingKey, gcm(fromBase64(w.iv)), AES, extractable, usages)
}
async function deriveKek(password: string, kdf: Keyring['kdf']): Promise<CryptoKey> {
const base = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveKey'])
const params: Pbkdf2Params = { name: 'PBKDF2', hash: 'SHA-256', salt: fromBase64(kdf.salt), iterations: kdf.iterations }
return crypto.subtle.deriveKey(params, base, AES, false, MASTER_USAGES)
}
function newKdf(iterations: number): Keyring['kdf'] {
return { name: 'PBKDF2', hash: 'SHA-256', iterations, salt: toBase64(randomBytes(16)) }
}
/** 解析服务器返回的密钥环;格式不符时抛错(不猜测、不兼容未知版本) */
export function parseKeyring(text: string): Keyring {
const k = JSON.parse(text) as Keyring
if (k?.v !== 1 || k.kdf?.name !== 'PBKDF2' || k.kdf.hash !== 'SHA-256' || !k.key?.iv || !k.key.data) {
throw new Error('密钥环格式无法识别')
}
return k
}
/** 生成新的密钥环,返回它和已解开的主密钥(iterations 仅供测试缩短耗时) */
export async function createKeyring(password: string, iterations = PBKDF2_ITERATIONS): Promise<{ keyring: Keyring; master: CryptoKey }> {
const kdf = newKdf(iterations)
const kek = await deriveKek(password, kdf)
const extractable = await crypto.subtle.generateKey(AES, true, MASTER_USAGES)
const keyring: Keyring = { v: 1, kdf, key: await wrap(extractable, kek) }
// 内存中只保留不可导出的副本
return { keyring, master: await unwrap(keyring.key, kek, MASTER_USAGES) }
}
/** 用加密密码解开主密钥;密码错误时抛出 WrongPasswordError */
export async function unlockKeyring(keyring: Keyring, password: string): Promise<CryptoKey> {
const kek = await deriveKek(password, keyring.kdf)
try {
return await unwrap(keyring.key, kek, MASTER_USAGES)
} catch (err) {
throw isAuthFailure(err) ? new WrongPasswordError() : err
}
}
/** 修改加密密码:主密钥不变,只用新密码重新包裹,已加密的笔记无需改动 */
export async function rewrapKeyring(keyring: Keyring, oldPassword: string, newPassword: string, iterations = PBKDF2_ITERATIONS): Promise<Keyring> {
const oldKek = await deriveKek(oldPassword, keyring.kdf)
let master: CryptoKey
try {
master = await unwrap(keyring.key, oldKek, MASTER_USAGES, true)
} catch (err) {
throw isAuthFailure(err) ? new WrongPasswordError() : err
}
const kdf = newKdf(iterations)
return { v: 1, kdf, key: await wrap(master, await deriveKek(newPassword, kdf)) }
}
export async function newNoteKey(master: CryptoKey): Promise<NoteKey> {
const key = await crypto.subtle.generateKey(AES, true, DATA_USAGES)
return { key, wrapped: await wrap(key, master) }
}
export async function encryptNote(nk: NoteKey, text: string): Promise<string> {
const iv = randomBytes(IV_BYTES)
const data = await crypto.subtle.encrypt(gcm(iv), nk.key, encoder.encode(text))
const env: NoteEnvelope = { v: 1, key: nk.wrapped, iv: toBase64(iv), data: toBase64(new Uint8Array(data)) }
return JSON.stringify(env)
}
/** 解密笔记,同时返回笔记密钥供之后保存时使用 */
export async function decryptNote(master: CryptoKey, file: string): Promise<{ text: string; key: NoteKey }> {
let env: NoteEnvelope
try {
env = JSON.parse(file) as NoteEnvelope
} catch {
throw new Error('加密笔记格式无效')
}
if (env?.v !== 1 || !env.key || !env.iv || typeof env.data !== 'string') throw new Error('加密笔记格式无效')
try {
const key = await unwrap(env.key, master, DATA_USAGES)
const text = decoder.decode(await crypto.subtle.decrypt(gcm(fromBase64(env.iv)), key, fromBase64(env.data)))
return { text, key: { key, wrapped: env.key } }
} catch (err) {
throw isAuthFailure(err) ? new Error('解密失败:文件已损坏或不是用当前密钥加密的') : err
}
}
// 加密图片为二进制(避免 base64 膨胀):文件头 | 密钥 iv | 包裹的密钥 | 内容 iv | 密文。
// 服务器只保存为 .enc,原始类型(如 image/png)放在明文开头:1 字节长度 + 类型
export async function encryptAsset(master: CryptoKey, bytes: ArrayBuffer, type: string): Promise<Uint8Array<ArrayBuffer>> {
const mime = encoder.encode(type)
if (mime.length > 255) throw new Error('图片类型过长')
const key = await crypto.subtle.generateKey(AES, true, DATA_USAGES)
const keyIv = randomBytes(IV_BYTES)
const wrapped = new Uint8Array(await crypto.subtle.wrapKey('raw', key, master, gcm(keyIv)))
const plain = new Uint8Array(1 + mime.length + bytes.byteLength)
plain[0] = mime.length
plain.set(mime, 1)
plain.set(new Uint8Array(bytes), 1 + mime.length)
const iv = randomBytes(IV_BYTES)
const cipher = new Uint8Array(await crypto.subtle.encrypt(gcm(iv), key, plain))
const out = new Uint8Array(ASSET_MAGIC.length + IV_BYTES + WRAPPED_KEY_BYTES + IV_BYTES + cipher.length)
let at = 0
for (const part of [ASSET_MAGIC, keyIv, wrapped, iv, cipher]) {
out.set(part, at)
at += part.length
}
return out
}
export async function decryptAsset(master: CryptoKey, file: ArrayBuffer): Promise<Blob> {
const bytes = new Uint8Array(file)
const header = ASSET_MAGIC.length + IV_BYTES + WRAPPED_KEY_BYTES + IV_BYTES
if (bytes.length < header || ASSET_MAGIC.some((b, i) => bytes[i] !== b)) throw new Error('加密图片格式无效')
let at = ASSET_MAGIC.length
const take = (n: number) => bytes.slice(at, (at += n))
const keyIv = take(IV_BYTES)
const wrapped = take(WRAPPED_KEY_BYTES)
const iv = take(IV_BYTES)
try {
const key = await crypto.subtle.unwrapKey('raw', wrapped, master, gcm(keyIv), AES, false, DATA_USAGES)
const plain = new Uint8Array(await crypto.subtle.decrypt(gcm(iv), key, bytes.subarray(at)))
const type = decoder.decode(plain.subarray(1, 1 + plain[0]))
return new Blob([plain.subarray(1 + plain[0])], { type })
} catch (err) {
throw isAuthFailure(err) ? new Error('解密失败:图片已损坏或不是用当前密钥加密的') : err
}
}
-100
View File
@@ -1,100 +0,0 @@
// 加密会话:解锁后主密钥只在内存中,关闭页面即失效(用户选择不做空闲超时),另可手动锁定。
// 笔记的读写与转换在 workspace.ts,这里只管主密钥、密码对话框与加密图片
import { reactive } from 'vue'
import { api } from './api'
import { createKeyring, decryptAsset, encryptAsset, parseKeyring, rewrapKeyring, unlockKeyring } from './crypto'
export type KeyDialogMode = 'unlock' | 'setup' | 'change'
export const keyState = reactive({
// 浏览器只在安全上下文(HTTPS 或 localhost)提供 WebCrypto
supported: window.isSecureContext && globalThis.crypto?.subtle !== undefined,
unlocked: false,
dialog: null as KeyDialogMode | null,
})
let master: CryptoKey | null = null
let dialogDone: ((ok: boolean) => void) | null = null
// 已解密图片:/api/v1/raw 地址 → blob 地址。图片文件名是内容哈希,内容不会变
const images = new Map<string, Promise<string>>()
export function masterKey(): CryptoKey {
if (!master) throw new Error('加密笔记已锁定')
return master
}
/** 确保已解锁:未解锁时弹出解锁对话框(从未设置过密码则为设置对话框),返回是否已解锁 */
export async function ensureUnlocked(): Promise<boolean> {
if (master) return true
if (!keyState.supported) return false
return openKeyDialog((await api.keyring()) ? 'unlock' : 'setup')
}
/** 打开密码对话框,在对话框完成(true)或取消(false)时返回 */
export function openKeyDialog(mode: KeyDialogMode): Promise<boolean> {
dialogDone?.(false)
keyState.dialog = mode
return new Promise((resolve) => (dialogDone = resolve))
}
export function closeKeyDialog(ok: boolean) {
keyState.dialog = null
const done = dialogDone
dialogDone = null
done?.(ok)
}
export async function unlock(password: string) {
const k = await api.keyring()
if (!k) throw new Error('尚未设置加密密码')
master = await unlockKeyring(parseKeyring(k.text), password)
keyState.unlocked = true
}
/** 首次设置加密密码;其他设备抢先设置时服务器返回 409 */
export async function setupKeyring(password: string) {
const created = await createKeyring(password)
await api.putKeyring(JSON.stringify(created.keyring), '')
master = created.master
keyState.unlocked = true
}
export async function changePassword(oldPassword: string, newPassword: string) {
const k = await api.keyring()
if (!k) throw new Error('尚未设置加密密码')
const next = await rewrapKeyring(parseKeyring(k.text), oldPassword, newPassword)
await api.putKeyring(JSON.stringify(next), k.rev)
}
/** 丢弃主密钥与已解密的图片;打开的加密笔记由调用方处理 */
export function lockKey() {
master = null
keyState.unlocked = false
for (const url of images.values()) void url.then(URL.revokeObjectURL, () => {})
images.clear()
}
export function loadEncryptedImage(url: string): Promise<string> {
let loading = images.get(url)
if (!loading) {
loading = api
.raw(url)
.then(async (blob) => URL.createObjectURL(await decryptAsset(masterKey(), await blob.arrayBuffer())))
// 失败的不缓存,下次显示时重试
loading.catch(() => images.delete(url))
images.set(url, loading)
}
return loading
}
/** 加密图片供上传;服务器对加密笔记的图片一律存为 .enc,类型记录在密文中 */
export async function encryptImage(image: Blob): Promise<File> {
return new File([await encryptAsset(masterKey(), await image.arrayBuffer(), image.type)], 'image.enc')
}
/** 解密图片供上传为普通图片;扩展名取自原始类型(如 image/svg+xml → .svg),服务器据此命名 */
export async function decryptImage(file: Blob): Promise<File> {
const image = await decryptAsset(masterKey(), await file.arrayBuffer())
const ext = /^image\/([a-z0-9]{1,8})/.exec(image.type)?.[1] ?? 'bin'
return new File([image], 'image.' + ext, { type: image.type })
}
+5 -28
View File
@@ -1,11 +1,6 @@
import { encodePath } from './api' import { encodePath } from './api'
export const NOTE_EXT = '.md' export const NOTE_EXT = '.md'
export const ENCRYPTED_EXT = '.md.enc'
export function isEncrypted(path: string): boolean {
return path.endsWith(ENCRYPTED_EXT)
}
export function dirname(path: string): string { export function dirname(path: string): string {
const i = path.lastIndexOf('/') const i = path.lastIndexOf('/')
@@ -21,22 +16,12 @@ export function joinPath(dir: string, name: string): string {
} }
export function noteTitle(path: string): string { export function noteTitle(path: string): string {
return basename(path).replace(/\.md(\.enc)?$/, '') return basename(path).replace(/\.md$/, '')
} }
/** 笔记的图片目录(Typora 风格,加密与未加密的同名笔记共用) */ // 把笔记中的图片地址解析为可加载的 URL:绝对地址原样返回,相对地址基于笔记所在目录走 /api/v1/raw
export function assetsDirOf(notePath: string): string { export function resolveAssetUrl(notePath: string, src: string): string {
return joinPath(dirname(notePath), noteTitle(notePath) + '.assets') if (/^[a-z][a-z\d+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('#')) return src
}
// 与服务端 vault.EscapeLinkPath 一致:只转义会破坏 Markdown 链接语法的字符
export function escapeLinkPath(s: string): string {
return s.replace(/[ ()<>]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
}
/** 笔记中相对地址的图片在仓库中的路径;绝对地址、站内绝对路径与锚点返回 null */
export function assetPathOf(notePath: string, src: string): string | null {
if (/^[a-z][a-z\d+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('#')) return null
const parts = dirname(notePath) ? dirname(notePath).split('/') : [] const parts = dirname(notePath) ? dirname(notePath).split('/') : []
for (const seg of src.split(/[?#]/)[0].split('/')) { for (const seg of src.split(/[?#]/)[0].split('/')) {
if (seg === '' || seg === '.') continue if (seg === '' || seg === '.') continue
@@ -51,13 +36,5 @@ export function assetPathOf(notePath: string, src: string): string | null {
parts.push(decoded) parts.push(decoded)
} }
} }
return parts.join('/') return '/api/v1/raw/' + encodePath(parts.join('/'))
} }
// 把笔记中的图片地址解析为可加载的 URL:绝对地址原样返回,相对地址基于笔记所在目录走 /api/v1/raw
export function resolveAssetUrl(notePath: string, src: string): string {
const p = assetPathOf(notePath, src)
return p === null ? src : RAW_PREFIX + encodePath(p)
}
export const RAW_PREFIX = '/api/v1/raw/'
+19 -237
View File
@@ -5,38 +5,12 @@ import { EditorView } from '@codemirror/view'
import { nextTick, reactive, watch } from 'vue' import { nextTick, reactive, watch } from 'vue'
import { createEditorState, pasteImages } from '../editor' import { createEditorState, pasteImages } from '../editor'
import { handleShortcut, registerCommand } from './commands' import { handleShortcut, registerCommand } from './commands'
import { imageLinks, rewriteImageLinks } from '../markdown/links' import { api, ApiError, type Entry } from './api'
import { api, ApiError, encodePath, type Entry } from './api'
import { decryptNote, encryptNote, newNoteKey, type NoteKey } from './crypto'
import { drafts } from './drafts' import { drafts } from './drafts'
import {
decryptImage,
encryptImage,
ensureUnlocked,
keyState,
loadEncryptedImage,
lockKey,
masterKey,
openKeyDialog,
} from './encryption'
import { connectEvents, type VaultEvent } from './events' import { connectEvents, type VaultEvent } from './events'
import { import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
assetPathOf,
assetsDirOf,
basename,
dirname,
ENCRYPTED_EXT,
escapeLinkPath,
isEncrypted,
joinPath,
NOTE_EXT,
noteTitle,
RAW_PREFIX,
resolveAssetUrl,
} from './paths'
// locked:加密笔记未解锁(或浏览器不支持加密),内容未载入 export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error' | 'locked'
export interface Tab { export interface Tab {
path: string path: string
@@ -85,8 +59,6 @@ const MAX_RETRIES = 12
const editorStates = new Map<string, EditorState>() const editorStates = new Map<string, EditorState>()
// 每个打开的笔记一个可变引用,编辑器扩展通过它取得笔记的当前路径(重命名后随之更新) // 每个打开的笔记一个可变引用,编辑器扩展通过它取得笔记的当前路径(重命名后随之更新)
const noteRefs = new Map<string, { path: string }>() const noteRefs = new Map<string, { path: string }>()
// 打开的加密笔记的笔记密钥,保存时用它加密
const noteKeys = new Map<string, NoteKey>()
interface SaveSlot { interface SaveSlot {
timer?: ReturnType<typeof setTimeout> timer?: ReturnType<typeof setTimeout>
@@ -129,10 +101,7 @@ export function dismissToast(id: number) {
} }
function errorText(err: unknown): string { function errorText(err: unknown): string {
if (err instanceof ApiError) return err.message return err instanceof ApiError ? err.message : '网络错误,请检查连接'
// fetch 的网络错误是 TypeError;其余(如解密失败)给出原因
if (err instanceof TypeError || !(err instanceof Error)) return '网络错误,请检查连接'
return err.message
} }
// ---- 编辑器状态 ---- // ---- 编辑器状态 ----
@@ -161,14 +130,13 @@ function buildState(path: string, doc: string): EditorState {
return createEditorState({ return createEditorState({
doc, doc,
resolveImage: (src) => resolveAssetUrl(ref.path, src), resolveImage: (src) => resolveAssetUrl(ref.path, src),
loadImage: (src) => (isEncrypted(ref.path) && src.startsWith(RAW_PREFIX) ? loadEncryptedImage(src) : null),
onChange: (u) => { onChange: (u) => {
editorStates.set(ref.path, u.state) editorStates.set(ref.path, u.state)
if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path) if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path)
}, },
extensions: [ extensions: [
pasteImages({ pasteImages({
upload: async (file) => api.upload(ref.path, isEncrypted(ref.path) ? await encryptImage(file) : file), upload: (file) => api.upload(ref.path, file),
onError: (err) => toast('图片上传失败:' + errorText(err)), onError: (err) => toast('图片上传失败:' + errorText(err)),
onPending: (d) => { onPending: (d) => {
slot(ref.path).uploads += d slot(ref.path).uploads += d
@@ -200,45 +168,11 @@ function replaceDoc(path: string, content: string) {
else editorStates.set(path, state.update(spec).state) else editorStates.set(path, state.update(spec).state)
} }
// 按地址映射改写打开的笔记中的图片链接。external 为 true 时不进入撤销历史、不触发保存
function editImageLinks(path: string, map: (url: string) => string | undefined, external: boolean) {
const active = path === ws.active && view !== null
const state = active ? view!.state : editorStates.get(path)
if (!state) return
const changes = imageLinks(state.doc.toString()).flatMap((l) => {
const insert = map(l.url)
return insert === undefined ? [] : [{ from: l.from, to: l.to, insert }]
})
if (!changes.length) return
const spec = { changes, annotations: external ? [externalChange.of(true), Transaction.addToHistory.of(false)] : [] }
if (active) view!.dispatch(spec)
else {
editorStates.set(path, state.update(spec).state)
if (!external) scheduleSave(path)
}
}
function docOf(path: string): string | undefined { function docOf(path: string): string | undefined {
if (path === ws.active && view && editorStates.has(path)) return view.state.doc.toString() if (path === ws.active && view && editorStates.has(path)) return view.state.doc.toString()
return editorStates.get(path)?.doc.toString() return editorStates.get(path)?.doc.toString()
} }
// ---- 读写(加密笔记在浏览器端加解密) ----
/** 读取笔记;加密笔记返回解密后的正文及其笔记密钥(须已解锁) */
async function readNote(path: string): Promise<{ content: string; rev: string; key?: NoteKey }> {
const file = await api.readFile(path)
if (!isEncrypted(path)) return file
const { text, key } = await decryptNote(masterKey(), file.content)
return { content: text, rev: file.rev, key }
}
function encryptFor(path: string, content: string): Promise<string> {
const key = noteKeys.get(path)
if (!key) throw new Error('缺少笔记密钥:' + path)
return encryptNote(key, content)
}
// ---- 保存队列 ---- // ---- 保存队列 ----
function slot(path: string): SaveSlot { function slot(path: string): SaveSlot {
@@ -273,16 +207,12 @@ export async function flush(path: string, keepalive = false): Promise<void> {
if (content === undefined) return if (content === undefined) return
const seq = s.seq const seq = s.seq
const baseRev = tab.rev const baseRev = tab.rev
const encrypted = isEncrypted(path)
s.inflight = (async () => { s.inflight = (async () => {
// 草稿与请求并行:页面关闭时等待 IndexedDB 写完再发请求,请求往往来不及发出 // 草稿与请求并行:页面关闭时等待 IndexedDB 写完再发请求,请求往往来不及发出
// 草稿只是兜底,写入失败不影响保存本身。加密笔记不写草稿,避免明文落在本地 // 草稿只是兜底,写入失败不影响保存本身
const draftSaved = encrypted const draftSaved = drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch((err) => console.warn('写入本地草稿失败', err))
? undefined
: drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch((err) => console.warn('写入本地草稿失败', err))
try { try {
// 未加密的笔记不能在发请求前 await:页面关闭时异步之后的请求可能发不出去 const rev = await api.writeFile(path, content, baseRev, keepalive)
const rev = await api.writeFile(path, encrypted ? await encryptFor(path, content) : content, baseRev, keepalive)
await draftSaved await draftSaved
tab.rev = rev tab.rev = rev
tab.saveError = undefined tab.saveError = undefined
@@ -290,7 +220,7 @@ export async function flush(path: string, keepalive = false): Promise<void> {
s.retries = 0 s.retries = 0
if (s.seq === seq) { if (s.seq === seq) {
tab.dirty = false tab.dirty = false
if (!encrypted) await drafts.delete(path) await drafts.delete(path)
} }
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 412) { if (err instanceof ApiError && err.status === 412) {
@@ -327,8 +257,7 @@ export async function useServerVersion(path: string) {
const tab = tabOf(path) const tab = tabOf(path)
if (!tab) return if (!tab) return
try { try {
const { content, rev, key } = await readNote(path) const { content, rev } = await api.readFile(path)
if (key) noteKeys.set(path, key)
replaceDoc(path, content) replaceDoc(path, content)
const s = slot(path) const s = slot(path)
s.savedSeq = s.seq s.savedSeq = s.seq
@@ -345,8 +274,7 @@ export async function keepLocalVersion(path: string) {
const content = docOf(path) const content = docOf(path)
if (!tab || content === undefined) return if (!tab || content === undefined) return
try { try {
const body = isEncrypted(path) ? await encryptFor(path, content) : content const rev = await api.writeFile(path, content, tab.conflictRev ?? '')
const rev = await api.writeFile(path, body, tab.conflictRev ?? '')
const s = slot(path) const s = slot(path)
s.savedSeq = s.seq s.savedSeq = s.seq
Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined }) Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined })
@@ -408,16 +336,11 @@ export async function openNote(path: string) {
ws.active = path ws.active = path
return return
} }
const locked = isEncrypted(path) && !keyState.unlocked const tab: Tab = { path, rev: '', status: 'loading', dirty: false }
ws.tabs.push({ path, rev: '', status: locked ? 'locked' : 'loading', dirty: false }) ws.tabs.push(tab)
ws.active = path ws.active = path
if (!locked) await loadTab(path)
}
async function loadTab(path: string) {
try { try {
const { content, rev, key } = await readNote(path) const { content, rev } = await api.readFile(path)
if (key) noteKeys.set(path, key)
editorStates.set(path, buildState(path, content)) editorStates.set(path, buildState(path, content))
const t = tabOf(path) const t = tabOf(path)
if (t) Object.assign(t, { rev, status: 'ready' }) if (t) Object.assign(t, { rev, status: 'ready' })
@@ -445,7 +368,6 @@ export async function closeTab(path: string) {
function forget(path: string) { function forget(path: string) {
editorStates.delete(path) editorStates.delete(path)
noteRefs.delete(path) noteRefs.delete(path)
noteKeys.delete(path)
const s = slots.get(path) const s = slots.get(path)
if (s) clearTimeout(s.timer) if (s) clearTimeout(s.timer)
slots.delete(path) slots.delete(path)
@@ -465,7 +387,7 @@ function remapOpenNotes(from: string, to: string) {
const old = tab.path const old = tab.path
tab.path = next tab.path = next
if (ws.active === old) ws.active = next if (ws.active === old) ws.active = next
for (const map of [editorStates, noteRefs, noteKeys, slots] as Map<string, unknown>[]) { for (const map of [editorStates, noteRefs, slots] as Map<string, unknown>[]) {
if (map.has(old)) { if (map.has(old)) {
map.set(next, map.get(old)) map.set(next, map.get(old))
map.delete(old) map.delete(old)
@@ -548,8 +470,7 @@ function uniqueName(dir: string, base: string, ext: string): string {
const taken = new Set(ws.entries.map((e) => e.path)) const taken = new Set(ws.entries.map((e) => e.path))
for (let i = 0; ; i++) { for (let i = 0; ; i++) {
const name = base + (i ? ` ${i}` : '') + ext const name = base + (i ? ` ${i}` : '') + ext
// 同名的加密笔记与未加密笔记不能共存(共用图片目录) if (!taken.has(joinPath(dir, name))) return joinPath(dir, name)
if (!taken.has(joinPath(dir, name)) && !(ext === NOTE_EXT && taken.has(joinPath(dir, name + '.enc')))) return joinPath(dir, name)
} }
} }
@@ -624,8 +545,7 @@ export function renameTarget(path: string, name: string): string | null {
name = name.trim().replace(/\//g, '') name = name.trim().replace(/\//g, '')
if (!name || name.startsWith('.')) return null if (!name || name.startsWith('.')) return null
const entry = ws.entries.find((e) => e.path === path) const entry = ws.entries.find((e) => e.path === path)
const ext = isEncrypted(path) ? ENCRYPTED_EXT : NOTE_EXT if (entry && !entry.dir && !name.endsWith(NOTE_EXT)) name += NOTE_EXT
if (entry && !entry.dir && !name.endsWith(ext)) name += ext
return joinPath(dirname(path), name) return joinPath(dirname(path), name)
} }
@@ -636,9 +556,6 @@ export async function move(from: string, to: string) {
toast(`“${basename(to)}”已存在`) toast(`“${basename(to)}”已存在`)
return return
} }
// 服务器读不到加密笔记的正文,无法随图片目录改名改写其中的链接,须解锁后由浏览器改写
const relink = isEncrypted(from) && noteTitle(from) !== noteTitle(to)
if (relink && !(await unlockNotes())) return
await withOp(async () => { await withOp(async () => {
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径 // 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
await Promise.all(affected(from).map((p) => flush(p))) await Promise.all(affected(from).map((p) => flush(p)))
@@ -659,7 +576,6 @@ export async function move(from: string, to: string) {
} finally { } finally {
paused.forEach((s) => s.paused--) paused.forEach((s) => s.paused--)
} }
affected(to).forEach(resumeSaving)
// 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步 // 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步
await Promise.all(affected(to).map(async (p) => { await Promise.all(affected(to).map(async (p) => {
const tab = tabOf(p) const tab = tabOf(p)
@@ -670,39 +586,10 @@ export async function move(from: string, to: string) {
if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev }) if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev })
} else await syncOpenTab(tab) } else await syncOpenTab(tab)
})) }))
if (relink) await relinkRenamedNote(from, to)
}) })
void refreshTree() void refreshTree()
} }
// 暂停期间到期的保存计时器绑定的是旧路径,移动/转换后按新路径重新安排
function resumeSaving(path: string) {
const s = slots.get(path)
if (!s || s.seq === s.savedSeq) return
clearTimeout(s.timer)
s.timer = setTimeout(() => void flush(path), SAVE_DELAY)
}
async function relinkRenamedNote(from: string, to: string) {
const oldDir = assetsDirOf(from)
const newDir = escapeLinkPath(basename(assetsDirOf(to)))
const map = (url: string) => {
const p = assetPathOf(from, url)
return p !== null && dirname(p) === oldDir ? newDir + '/' + escapeLinkPath(basename(p)) : undefined
}
if (tabOf(to)?.status === 'ready') {
editImageLinks(to, map, false)
return
}
try {
const { content, rev, key } = await readNote(to)
const next = rewriteImageLinks(content, map)
if (next !== content) await api.writeFile(to, await encryptNote(key!, next), rev)
} catch (err) {
toast('改写图片链接失败:' + errorText(err))
}
}
export async function remove(path: string) { export async function remove(path: string) {
const entry = ws.entries.find((e) => e.path === path) const entry = ws.entries.find((e) => e.path === path)
if (!entry) return if (!entry) return
@@ -714,7 +601,7 @@ export async function remove(path: string) {
for (const p of paths) await closeTab(p) for (const p of paths) await closeTab(p)
try { try {
const item = await api.deleteFile(path) const item = await api.deleteFile(path)
toast(`已删除“${noteTitle(path)}”`, { toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, {
label: '撤销', label: '撤销',
run: () => void restore(item.id), run: () => void restore(item.id),
}) })
@@ -741,9 +628,8 @@ async function syncOpenTab(tab: Tab) {
const s = slots.get(tab.path) const s = slots.get(tab.path)
if (tab.status !== 'ready' || tab.dirty || s?.inflight) return if (tab.status !== 'ready' || tab.dirty || s?.inflight) return
try { try {
const { content, rev, key } = await readNote(tab.path) const { content, rev } = await api.readFile(tab.path)
if (rev === tab.rev || tab.dirty) return if (rev === tab.rev || tab.dirty) return
if (key) noteKeys.set(tab.path, key)
replaceDoc(tab.path, content) replaceDoc(tab.path, content)
tab.rev = rev tab.rev = rev
} catch (err) { } catch (err) {
@@ -779,100 +665,6 @@ async function resync() {
await Promise.all(ws.tabs.map(syncOpenTab)) await Promise.all(ws.tabs.map(syncOpenTab))
} }
// ---- 加密 ----
/** 解锁并载入处于锁定状态的标签页,返回是否已解锁 */
export async function unlockNotes(): Promise<boolean> {
try {
if (!(await ensureUnlocked())) return false
} catch (err) {
toast('读取加密密钥失败:' + errorText(err))
return false
}
await Promise.all(ws.tabs.filter((t) => t.status === 'locked').map((t) => ((t.status = 'loading'), loadTab(t.path))))
return true
}
/** 锁定:丢弃主密钥和已打开的加密笔记内容。有保存失败的修改时不锁定,否则修改会丢失 */
export async function lockNotes() {
const tabs = ws.tabs.filter((t) => isEncrypted(t.path))
await Promise.all(tabs.map((t) => flush(t.path)))
if (tabs.some((t) => t.dirty)) {
toast('有加密笔记的修改尚未保存成功,暂不能锁定')
return
}
lockKey()
for (const t of tabs) {
editorStates.delete(t.path)
noteKeys.delete(t.path)
Object.assign(t, { status: 'locked', conflictRev: undefined, saveError: undefined })
}
toast('已锁定加密笔记')
}
/**
* 加密或取消加密笔记(a.md ↔ a.md.enc)。本篇图片目录中被引用的图片一并加密/解密为新文件并改写链接,
* 转换成功后永久删除原图片(加密前的明文图片不应留在回收站)
*/
export async function toggleEncryption(path: string) {
if (!(await unlockNotes())) return
const tab = tabOf(path)
if (!tab || tab.status !== 'ready') return
const encrypting = !isEncrypted(path)
const to = encrypting ? path.slice(0, -NOTE_EXT.length) + ENCRYPTED_EXT : path.slice(0, -ENCRYPTED_EXT.length) + NOTE_EXT
const action = encrypting ? '加密' : '取消加密'
await withOp(async () => {
await flush(path)
if (tab.dirty || tab.status !== 'ready') {
toast(`笔记尚未保存成功,暂不能${action}`)
return
}
// 转换期间暂停保存;期间的输入在转换后保存到新路径
const s = slot(path)
s.paused++
const replaced = new Map<string, string>()
const converted: string[] = []
try {
const text = docOf(path)!
for (const { url } of imageLinks(text)) {
const p = assetPathOf(path, url)
if (replaced.has(url) || p === null || dirname(p) !== assetsDirOf(path) || p.endsWith('.enc') === encrypting) continue
let file: Blob
try {
file = await api.raw(RAW_PREFIX + encodePath(p))
} catch (err) {
// 链接指向的图片已不存在,没有可转换的
if (err instanceof ApiError && err.status === 404) continue
throw err
}
replaced.set(url, await api.upload(to, encrypting ? await encryptImage(file) : await decryptImage(file)))
converted.push(p)
}
const content = rewriteImageLinks(text, (u) => replaced.get(u))
const key = encrypting ? await newNoteKey(masterKey()) : undefined
tab.rev = await api.convert(path, to, key ? await encryptNote(key, content) : content, tab.rev)
if (key) noteKeys.set(path, key)
else noteKeys.delete(path)
ws.entries = ws.entries.map((e) => (e.path === path ? { ...e, path: to } : e))
remapOpenNotes(path, to)
remapRecent(path, to)
// 已写入的内容含新链接;转换期间若有输入,其中的旧链接一并改写,随后续保存写入
editImageLinks(to, (u) => replaced.get(u), true)
} catch (err) {
toast(`${action}失败:` + errorText(err))
return
} finally {
s.paused--
}
resumeSaving(to)
toast(`已${action}“${noteTitle(to)}”`)
for (const p of converted) {
api.deleteAsset(p).catch((err) => toast('删除原图片失败:' + errorText(err)))
}
})
void refreshTree()
}
// ---- 启动 ---- // ---- 启动 ----
const TABS_KEY = 'rawmark.tabs' const TABS_KEY = 'rawmark.tabs'
@@ -942,10 +734,6 @@ export async function startWorkspace() {
if (document.visibilityState === 'hidden') void flushAll(true) if (document.visibilityState === 'hidden') void flushAll(true)
}) })
window.addEventListener('pagehide', () => void flushAll(true)) window.addEventListener('pagehide', () => void flushAll(true))
// 加密笔记要先异步加密才能发出请求,页面关闭时往往来不及,且没有本地草稿兜底:有未保存的修改时请浏览器确认
window.addEventListener('beforeunload', (e) => {
if (ws.tabs.some((t) => t.dirty && isEncrypted(t.path))) e.preventDefault()
})
window.addEventListener('keydown', handleShortcut) window.addEventListener('keydown', handleShortcut)
} }
@@ -954,7 +742,6 @@ registerBuiltinCommands()
function registerBuiltinCommands() { function registerBuiltinCommands() {
const hasNote = () => ws.active !== null const hasNote = () => ws.active !== null
const activeEncrypted = () => ws.active !== null && isEncrypted(ws.active)
const toggleSwitcher = (initial: string) => { const toggleSwitcher = (initial: string) => {
ws.switcher = ws.switcher === null ? initial : null ws.switcher = ws.switcher === null ? initial : null
} }
@@ -971,11 +758,6 @@ function registerBuiltinCommands() {
// 浏览器不允许网页覆盖 Ctrl+W // 浏览器不允许网页覆盖 Ctrl+W
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) }, { id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
{ id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) }, { id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) },
{ id: 'note.encrypt', title: '加密当前笔记', when: () => keyState.supported && hasNote() && !activeEncrypted(), run: () => void toggleEncryption(ws.active!) },
{ id: 'note.decrypt', title: '取消加密当前笔记', when: () => keyState.supported && activeEncrypted(), run: () => void toggleEncryption(ws.active!) },
{ id: 'vault.unlock', title: '解锁加密笔记', when: () => keyState.supported && !keyState.unlocked, run: () => void unlockNotes() },
{ id: 'vault.lock', title: '锁定加密笔记', when: () => keyState.unlocked, run: () => void lockNotes() },
{ id: 'vault.password', title: '修改加密密码', when: () => keyState.supported, run: () => void openKeyDialog('change') },
]) { ]) {
registerCommand(cmd) registerCommand(cmd)
} }
+1 -4
View File
@@ -8,7 +8,7 @@ import { typoraExtensions } from '../markdown/extensions'
import { renderedBlocks } from './blocks' import { renderedBlocks } from './blocks'
import { codeBlockHighlight } from './codeblocks' import { codeBlockHighlight } from './codeblocks'
import { editorFocus } from './focus' import { editorFocus } from './focus'
import { imageLoader, livePreview, resolveImageUrl } from './livepreview' import { livePreview, resolveImageUrl } from './livepreview'
import { tableEditing } from './table' import { tableEditing } from './table'
import { markdownTheme } from './theme' import { markdownTheme } from './theme'
@@ -26,8 +26,6 @@ const rawmarkLanguage = new Language(
export interface EditorStateOptions { export interface EditorStateOptions {
doc: string doc: string
resolveImage?: (src: string) => string resolveImage?: (src: string) => string
// 见 imageLoader
loadImage?: (src: string) => Promise<string> | null
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新 // 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
onChange?: (update: ViewUpdate) => void onChange?: (update: ViewUpdate) => void
extensions?: Extension[] extensions?: Extension[]
@@ -55,7 +53,6 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
EditorView.contentAttributes.of({ spellcheck: 'false', autocapitalize: 'sentences' }), EditorView.contentAttributes.of({ spellcheck: 'false', autocapitalize: 'sentences' }),
] ]
if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage)) if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
if (opts.loadImage) extensions.push(imageLoader.of(opts.loadImage))
if (opts.onChange) { if (opts.onChange) {
const onChange = opts.onChange const onChange = opts.onChange
extensions.push(EditorView.updateListener.of((u) => u.docChanged && onChange(u))) extensions.push(EditorView.updateListener.of((u) => u.docChanged && onChange(u)))
+1 -6
View File
@@ -21,11 +21,6 @@ export const resolveImageUrl = Facet.define<(src: string) => string, (src: strin
combine: (values) => values[0] ?? ((src) => src), combine: (values) => values[0] ?? ((src) => src),
}) })
// 需要先取回处理的图片(加密笔记的图片要下载后在浏览器解密):返回可显示的地址;返回 null 表示直接加载
export const imageLoader = Facet.define<(src: string) => Promise<string> | null, (src: string) => Promise<string> | null>({
combine: (values) => values[0] ?? (() => null),
})
const hidden = Decoration.replace({}) const hidden = Decoration.replace({})
const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' }) const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
const linkText = Decoration.mark({ class: 'cm-rm-link' }) const linkText = Decoration.mark({ class: 'cm-rm-link' })
@@ -252,7 +247,7 @@ function buildDecorations(view: EditorView): DecorationSet {
const marks = node.getChildren('LinkMark') const marks = node.getChildren('LinkMark')
if (!url || marks.length < 2) return if (!url || marks.length < 2) return
const src = resolveUrl(state.sliceDoc(url.from, url.to).replace(/^<|>$/g, '')) const src = resolveUrl(state.sliceDoc(url.from, url.to).replace(/^<|>$/g, ''))
const widget = new ImageWidget(src, state.sliceDoc(marks[0].to, marks[1].from), state.facet(imageLoader)) const widget = new ImageWidget(src, state.sliceDoc(marks[0].to, marks[1].from))
if (touches(node)) out.push(Decoration.widget({ widget, side: 1 }).range(node.to)) if (touches(node)) out.push(Decoration.widget({ widget, side: 1 }).range(node.to))
else if (lineOf(node.from) === lineOf(node.to)) out.push(Decoration.replace({ widget }).range(node.from, node.to)) else if (lineOf(node.from) === lineOf(node.to)) out.push(Decoration.replace({ widget }).range(node.from, node.to))
} }
+1 -4
View File
@@ -123,7 +123,6 @@ export class ImageWidget extends WidgetType {
constructor( constructor(
readonly src: string, readonly src: string,
readonly alt: string, readonly alt: string,
readonly load: (src: string) => Promise<string> | null = () => null,
) { ) {
super() super()
} }
@@ -134,9 +133,7 @@ export class ImageWidget extends WidgetType {
const wrap = document.createElement('span') const wrap = document.createElement('span')
wrap.className = 'cm-rm-image' wrap.className = 'cm-rm-image'
const img = document.createElement('img') const img = document.createElement('img')
const loading = this.load(this.src) img.src = this.src
if (loading) loading.then((url) => (img.src = url), () => img.dispatchEvent(new Event('error')))
else img.src = this.src
img.alt = this.alt img.alt = this.alt
img.loading = 'lazy' img.loading = 'lazy'
img.decoding = 'async' img.decoding = 'async'
-22
View File
@@ -1,22 +0,0 @@
import { expect, it } from 'vitest'
import { imageLinks, rewriteImageLinks } from './links'
it('只列出图片与引用定义的地址,跳过普通链接和代码', () => {
const src = [
'![a](a.assets/1.png) [链接](a.assets/2.png) ![b](<a.assets/有 空格.png> "标题")',
'',
'`![c](a.assets/3.png)`',
'',
'```',
'![d](a.assets/4.png)',
'```',
'',
'![e][ref]',
'',
'[ref]: a.assets/5.png',
].join('\n')
expect(imageLinks(src).map((l) => l.url)).toEqual(['a.assets/1.png', 'a.assets/有 空格.png', 'a.assets/5.png'])
expect(rewriteImageLinks(src, (u) => (u.endsWith('5.png') ? undefined : u.replace('a.assets', 'b.assets')))).toBe(
src.replace('a.assets/1.png', 'b.assets/1.png').replace('a.assets/有 空格', 'b.assets/有 空格'),
)
})
-37
View File
@@ -1,37 +0,0 @@
import { markdownParser } from './html'
/** 图片地址在源码中的位置;url 为书写形式(尖括号已去掉,未解码) */
export interface ImageLink {
from: number
to: number
url: string
}
/** 列出图片 ![](url) 与链接引用定义 [x]: url 中的地址(引用定义可能被图片使用);代码中的不算 */
export function imageLinks(src: string): ImageLink[] {
const out: ImageLink[] = []
markdownParser.parse(src).iterate({
enter(ref) {
if (ref.name !== 'Image' && ref.name !== 'LinkReference') return
const url = ref.node.getChild('URL')
if (!url) return
let { from, to } = url
if (src[from] === '<' && src[to - 1] === '>') [from, to] = [from + 1, to - 1]
out.push({ from, to, url: src.slice(from, to) })
},
})
return out
}
/** 替换图片地址:map 返回 undefined 的保持不变 */
export function rewriteImageLinks(src: string, map: (url: string) => string | undefined): string {
let out = ''
let at = 0
for (const l of imageLinks(src)) {
const next = map(l.url)
if (next === undefined) continue
out += src.slice(at, l.from) + next
at = l.to
}
return out + src.slice(at)
}