Unforget
立即开始,无需注册,访问 unforget.computing-den.com。
Unforget 是一款极简、离线优先且端到端加密的笔记应用(不使用 Electron.js),具有以下特点:
- 离线优先
- 隐私优先
- 渐进式 Web 应用
- 开源 MIT 许可证
- 端到端加密同步
- 支持桌面、移动和网页端
- Markdown 支持
- 自托管和云端选项
- 一键导出数据为 JSON 格式
- 可选一键安装
- 公开 API,可创建自己的客户端
- 导入 Google Keep
- 导入 Apple Notes
- 导入 Standard Notes
Unforget 由 Computing Den 开发,这是一家专注于 Web 技术的软件公司。
轻松注册
免费注册,将您的笔记安全地备份到云端,全程加密,并在各设备间同步。
无需提供电子邮件或电话。
可选安装
直接在浏览器中使用或安装:
浏览器 | 安装方式 |
---|---|
Chrome | 地址栏中的安装图标 |
Edge | 地址栏中的安装图标 |
Android 浏览器 | 菜单 → 添加到主屏幕 |
Safari 桌面版 | 共享 → 添加到程序坞 |
Safari iOS 版 | 共享 → 添加到主屏幕 |
Firefox 桌面版 | 无法安装 |
Firefox Android | 地址栏中的安装图标 |
组织和工作流程
笔记按时间顺序组织,置顶笔记显示在顶部。
尽管简单,但这种组织方式已被证明非常有效。搜索非常快速(离线进行),只需输入几个短语即可快速缩小笔记范围。此外,您还可以搜索非字母字符,从而使用标签,如 #idea、#project、#work、#book 等。
笔记大小没有限制。对于较长的笔记,您可以在单独的一行插入 ---
来折叠笔记的其余部分。
笔记在您输入时立即保存,并每隔几秒钟同步一次。
如果您从两个设备编辑同一笔记,同步时发生冲突,将以最近的编辑为准。
安全和隐私
Unforget 不会接收或存储任何个人数据。注册不需要电子邮件或电话。只要您选择一个强密码,您的笔记将被完全加密并安全地存储在云端。
Unforget 服务器只能看到您的用户名和笔记修改日期。
文本格式
与 Github 风格的 Markdown 的主要区别是:
- 如果笔记的第一行后面跟着一个空行,它将被视为 H1 标题。
- 笔记中第一个水平线
---
之后的所有内容都将被隐藏,并替换为一个"显示更多"按钮,点击后可展开笔记。
# H1 标题
## H2 标题
### H3 标题
#### H4 标题
##### H5 标题
###### H6 标题
*这是斜体。*
**这是粗体。**
***这是粗斜体。***
~~这是删除线~~
- 这是一个项目符号
- 另一个项目符号
- 内部项目符号
- [ ] 这是一个复选框
与复选框相关的更多文本。
1. 这是一个有序列表项
2. 另一个有序列表项
[这是一个链接](https://unforget.computing-den.com)
使用反引号的内联`代码`。
代码块:
```javascript
function plusOne(a) {
return a + 1;
}
```
| 表格 | 很 | 酷 |
| ------------ |:------------:| -----:|
| 第3列是 | 右对齐 | $1600 |
| 第2列是 | 居中 | $12 |
水平线:
---
构建和自托管
要为生产环境构建 Unforget,请在项目根目录下放置一个 .env
文件:
PORT=3000
NODE_ENV=production
DISABLE_CACHE=0
LOG_TO_CONSOLE=0
FORWARD_LOGS_TO_SERVER=0
FORWARD_ERRORS_TO_SERVER=0
然后运行
cd unforget/
npm run build
npm run start
建议使用 Nginx 作为反向代理,并使用 Let's Encrypt 设置 SSL 证书。
开发
要在开发模式下构建和运行 Unforget,请在项目根目录下放置一个 .env
文件:
PORT=3000
NODE_ENV=development
DISABLE_CACHE=1
LOG_TO_CONSOLE=1
FORWARD_LOGS_TO_SERVER=0
FORWARD_ERRORS_TO_SERVER=0
然后运行
cd unforget/
npm run dev
这将构建项目并监视源文件的变化。
公共 API - 编写您自己的客户端
这里的所有路径都相对于官方服务器 https://unforget.computing-den.com 或您自己的服务器(如果您在自托管)。
示例
在 examples/ 目录中,您会找到 TypeScript 和 Python 的示例代码。
要运行 TypeScript 示例:
cd examples/
# 注册
npx tsx example.ts signup USERNAME PASSWORD
# 登录
npx tsx example.ts login USERNAME PASSWORD
# 创建新笔记
npx tsx example.ts create "Hello world!"
# 获取所有笔记
npx tsx example.ts get
# 通过 ID 获取笔记
npx tsx example.ts get ID
要运行 Python 示例:
cd examples/
# 注册
python3 example.py signup USERNAME PASSWORD
# 登录
python3 example.py login USERNAME PASSWORD
# 创建新笔记
python3 example.py create "Hello world!"
# 获取所有笔记
python3 example.py get
# 通过 ID 获取笔记
python3 example.py get ID
笔记类型
type Note = {
// UUID 版本 4
id: string;
// 已删除的笔记 text 为 null
text: string | null;
// ISO 8601 格式
creation_date: string;
// ISO 8601 格式
modification_date: string;
// 0 表示已删除,1 表示未删除
not_deleted: number;
// 0 表示已归档,1 表示未归档
not_archived: number;
// 0 表示未置顶,1 表示已置顶
pinned: number;
// 数字越大,在列表中越靠前
// 通常,默认情况下是自纪元以来的毫秒数
order: number;
}
type EncryptedNote = {
// UUID 版本 4
id: string;
// ISO 8601 格式
modification_date: string;
// 加密的 Note,base64 格式
encrypted_base64: string;
// 初始向量,用于加密这个特定笔记的随机数
iv: string;
}
服务器只知道 EncryptedNote
,从不看到实际的 Note
。因此,客户端必须在发送到服务器之前加密笔记,并在从服务器接收笔记后解密。
附注:使用数字(0 和 1)而不是布尔值的原因是为了更容易将笔记存储在不支持布尔值的 SQLite 中。一些字段被反转(not_deleted
而不是 deleted
)的原因是为了方便使用 IndexedDB,它不支持按任意顺序索引多个键。
注册、登录、登出
要注册,向 /api/signup
发送 POST 请求,JSON 负载类型为 SignupData
:
type SignupData = {
username: string;
password_client_hash: string;
encryption_salt: string;
}
要登录,向 /api/login
发送 POST 请求,JSON 负载类型为 LoginData
:
type LoginData = {
username: string;
password_client_hash: string;
}
在这两种情况下,如果凭据错误,您将收到 401 错误。否则,服务器将响应 LoginResponse
和 200 状态码:
type LoginResponse = {
username: string;
token: string;
encryption_salt: string;
}
要登出,向 /api/login?token=TOKEN
发送 POST 请求
在以下章节中,所有对服务器的请求都必须包含 token
,可以作为 URL 中的查询参数(例如 /api/delta-sync?token=XXX
)或名为 unforget_token
的 cookie。
请注意,我们从不向服务器发送原始密码。相反,我们计算其哈希值 password_client_hash
,该值由用户名、密码和静态随机数派生而来。如果您希望能够使用官方 Unforget 客户端和您自己的客户端,使用完全相同的算法计算哈希值非常重要。encryption_salt
是用于派生加密和解密笔记的密钥的随机数。它存储在服务器上并在登录时提供。示例部分展示了如何计算哈希值和生成盐。
获取笔记
向 /api/get-notes?token=TOKEN
发送 POST 请求以获取所有笔记。您也可以选择提供 {ids: string[]}
类型的 JSON 负载来获取特定笔记。
您将收到 EncryptedNote[]
。
合并笔记
向 /api/merge-notes?token=TOKEN
发送 POST 请求,并附带 {notes: EncryptedNote[]}
类型的 JSON 负载。
如果笔记不存在,它将被添加。
如果其 modification_date
大于现有笔记,它将替换现有笔记。
否则,它将被丢弃。
删除笔记
要删除笔记,请将其 text
设为 null,not_deleted
设为 0,然后合并它。这样,存根将保留在数据库中,并且删除事实将传播到所有其他客户端。
同步和合并
对于长期运行的客户端,您可以使用以下方式进行同步,而不是使用获取笔记和合并笔记。
客户端和服务器各自维护一个要发送给对方的更改队列以及一个同步编号。这些更改的交换称为增量同步。
登录时同步编号为 0,只有在接收到的所有更改都已合并并存储后,每一方才会递增同步编号。在每次增量同步开始时,如果它们的同步编号不同,这表明上次增量同步出现问题,因此它们必须进行队列同步。
队列同步是指每一方发送其同步编号以及它所知道的所有笔记的 ID 和修改日期列表。队列同步后,双方都将知道对方缺少哪些更改,因此可以更新自己的队列和同步编号。
当同步编号为 0(登录后立即)时,服务器将在第一次增量同步中发送所有笔记。
要执行增量同步,向 /api/delta-sync?token=TOKEN
发送 POST 请求,并附带 SyncData
类型的 JSON 负载:
type SyncData = {
notes: EncryptedNote[];
syncNumber: number;
}
如果服务器同意 syncNumber
,它将响应 DeltaSyncResNormal
,其中包括自上次同步以来存储在服务器上的该客户端的更改。否则,服务器将响应 PartialSyncResRequireQueueSync
,要求客户端启动队列同步。
type DeltaSyncResNormal = {
type: 'ok';
notes: EncryptedNote[];
syncNumber: number;
}
type DeltaSyncResRequireQueueSync = {
type: 'require_queue_sync';
}
要执行队列同步,向 /api/queue-sync?token=TOKEN
发送 POST 请求,并附带 SyncHeadsData
类型的 JSON 负载,其中包括客户端已知的所有笔记的头部和其同步编号。然后您将收到另一个 SyncHeadsData
,其中包括服务器为该用户已知的所有笔记的头部以及服务器为该客户端的同步编号。
type SyncHeadsData = {
noteHeads: NoteHead[];
syncNumber: number;
}
type NoteHead = {
id: string;
modification_date: string;
}
队列同步后,每一方都会更新其队列以包括对方缺少的更改,并将新的同步编号设置为较大的同步编号 + 1。
重要的是,客户端和服务器要就笔记的合并方式达成一致,以便最终得到一致的状态。我们认为,如果 A.id == B.id
且 A.modification_date > B.modification_date
,则笔记 A 必须替换笔记 B。
加密和解密
加密和解密的详细信息在代码中更容易解释。请参阅示例部分。
错误处理
当遇到状态码 >= 400 的错误时,所有 API 调用都将返回 ServerError
类型的对象:
type ServerError {
message: string;
code: number;
type: 'app_requires_update' | 'generic';
}
如果您收到类型为 app_requires_update
的错误,这表明您正在使用不再支持的旧版 API。