Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aed039702 | ||
|
|
586c4bf049 | ||
|
|
3bbd7a2e1b | ||
|
|
179542cd26 | ||
|
|
64e992e172 | ||
|
|
0839f28e24 | ||
|
|
a908319f1e | ||
|
|
a82af4fb70 | ||
|
|
d09a782fde | ||
|
|
4b6f42c4d0 | ||
|
|
155f60cdd9 | ||
|
|
e119811800 | ||
|
|
5eb9a2b106 | ||
|
|
0e2889643c | ||
|
|
7551bda29c | ||
|
|
4c5011bf5f | ||
|
|
9c35f54560 | ||
|
|
a4d852fc6a | ||
|
|
8cd6a9121b | ||
|
|
c24fdf38a3 | ||
|
|
480aaff1c4 | ||
|
|
4f06d82a76 | ||
|
|
31733ca8f4 | ||
|
|
8f1e208adf | ||
|
|
439e43d144 | ||
|
|
6368563633 | ||
|
|
52c9a95c60 | ||
|
|
3b653176dc | ||
|
|
96fd7aa74c | ||
|
|
301ed3b614 | ||
|
|
c138bc81d2 | ||
|
|
874f8f7583 |
+43
-18
@@ -7,47 +7,60 @@ on:
|
||||
branches: [main, "feat/**", "fix/**", "chore/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
APP_EXPORT_FONT: /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
|
||||
RUSTUP_DIST_SERVER: https://rsproxy.cn
|
||||
RUSTUP_UPDATE_ROOT: https://rsproxy.cn/rustup
|
||||
UV_INSTALLER_GITHUB_BASE_URL: https://ghfast.top/https://github.com
|
||||
UV_DEFAULT_INDEX: https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- run: git diff --check
|
||||
- run: python scripts/check-doc-links.py
|
||||
- run: python3 scripts/check-doc-links.py
|
||||
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- run: pip install uv==0.9.24
|
||||
- name: 切换 Python 锁文件下载源
|
||||
run: python3 scripts/prepare-ci-uv-mirror.py
|
||||
- name: 安装 Rust 工具链
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: 安装 uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/0.9.24/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- run: uv sync --frozen
|
||||
working-directory: backend
|
||||
- run: uv run python -m compileall -q app
|
||||
working-directory: backend
|
||||
- run: uv run pytest
|
||||
working-directory: backend
|
||||
- run: python scripts/phase3-production-acceptance.py --list-cases --json
|
||||
- run: python3 scripts/phase3-production-acceptance.py --list-cases --json
|
||||
|
||||
service-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "22", cache: pnpm, cache-dependency-path: "server sync/console/pnpm-lock.yaml" }
|
||||
- name: 切换 Python 锁文件下载源
|
||||
run: python3 scripts/prepare-ci-uv-mirror.py
|
||||
- run: corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
- run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: server sync/console
|
||||
- run: git diff --exit-code -- "server sync/sync_server/static"
|
||||
- run: pip install uv==0.9.24
|
||||
- name: 安装 uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/0.9.24/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- run: uv sync --frozen
|
||||
working-directory: backend
|
||||
- run: uv sync --frozen && uv run pytest
|
||||
- run: uv sync --frozen && uv run pytest --deselect='tests/test_upload_benchmark.py::test_four_concurrent_uploads_over_real_http[104857600]'
|
||||
working-directory: server sync
|
||||
- run: uv sync --frozen && uv run pytest
|
||||
working-directory: community-server
|
||||
@@ -57,8 +70,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "22", cache: pnpm, cache-dependency-path: frontend/pnpm-lock.yaml }
|
||||
- run: corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
- run: pnpm install --frozen-lockfile
|
||||
working-directory: frontend
|
||||
@@ -69,7 +80,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: rustfmt, clippy }
|
||||
- run: cargo fmt --check && cargo test --lib --locked && cargo clippy --lib --locked -- -D warnings
|
||||
- name: 切换 Python 锁文件下载源
|
||||
run: python3 scripts/prepare-ci-uv-mirror.py
|
||||
- name: 安装 Rust 工具链
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --component rustfmt,clippy
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: 准备协议测试所需的后端环境
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/0.9.24/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
uv sync --frozen
|
||||
working-directory: backend
|
||||
- name: 运行 Rust 基础检查
|
||||
run: |
|
||||
cargo fmt --check
|
||||
cargo test --lib --locked -- --skip credentials::tests::b04_migration_survives_twenty_hard_terminations_per_boundary
|
||||
cargo clippy --lib --locked -- -D warnings
|
||||
working-directory: frontend/src-tauri
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
OpenNexus 是一款本地优先的 AI 笔记与知识中枢。它将 Markdown Vault、全文与向量检索、知识库问答、可审计 Agent、扩展系统和多设备同步整合在一个桌面应用中。笔记与索引由用户掌控;需要模型或同步服务时,再按需连接本地或远程服务。
|
||||
|
||||
当前发布版本为 **0.3.0-alpha.1**,主要支持 Windows x64。Alpha 版本用于验证完整业务闭环和部署方案,升级前请备份 Vault。
|
||||
当前发布版本为 **0.3.1-alpha.3**,主要支持 Windows x64。Alpha 版本仍处于快速迭代阶段,升级前请备份 Vault。
|
||||
|
||||
## 主要能力
|
||||
|
||||
@@ -34,14 +34,24 @@ flowchart LR
|
||||
|
||||
## 使用发布包
|
||||
|
||||
1. 下载 Windows x64 软件包,并核对发布页中的 SHA-256。
|
||||
2. 将便携版完整解压到可写目录,不要单独移动可执行文件。
|
||||
3. 启动 `OpenNexus.exe`,选择已有 Vault 或创建新 Vault。
|
||||
本版提供 Windows x64 EXE 安装包和独立的 Server Sync 包,下载入口见 [v0.3.1-alpha.3 发布页](https://gitea.kronecker.cc/Kronecker/NotesAgentic/releases/tag/v0.3.1-alpha.3)。发布页同时附带 `SHA256.json`,用于核对文件完整性。
|
||||
|
||||
安装包不包含任何 Vault 或用户数据,也不预装已下载的社区主题、本地模型权重、CUDA 与 PyTorch 运行时。相关功能仍完整保留;需要时可在客户端内按需安装主题、选择模型或配置 CUDA 环境。程序自带的基础界面样式属于客户端资源,不视为社区主题。同一 Windows 用户下升级安装会继续使用 `%APPDATA%\cc.kronecker.notesagent` 中的既有配置和索引,以及用户此前选择的外部 Vault。
|
||||
|
||||
1. 下载 Windows x64 EXE 安装包,并核对发布页中的 SHA-256。
|
||||
2. 运行安装程序,按向导完成当前用户安装;未签名的 Alpha 包可能触发 Windows 未知发布者提示。
|
||||
3. 从开始菜单启动 OpenNexus,选择已有 Vault 或创建新 Vault。
|
||||
4. 在“设置 → 模型提供商”中配置本地模型或远程模型凭据。
|
||||
5. 如需多设备同步,在同步设置中填写管理员提供的 Sync Server 地址并登录。
|
||||
|
||||
凭据不会写入前端 `localStorage`。首次试用建议复制一份现有笔记目录,再用副本验证索引和同步行为。
|
||||
|
||||
### 工作区图片存储
|
||||
|
||||
在源码或所见即所得编辑器中粘贴、拖入或选择 PNG、JPEG、GIF、WebP 图片后,OpenNexus 会按内容哈希保存到当前 Vault 的 `attachments/<哈希前两位>/<SHA-256>.<扩展名>`。Markdown 使用相对路径引用图片,因此笔记目录整体复制、导出或同步后仍可定位原图;单张图片上限为 5 MiB,相同内容只保存一份。
|
||||
|
||||
图片二进制不写入 SQLite。数据库中的 `workspace_assets` 保存路径、SHA-256、媒体类型、大小和原始文件名,`workspace_asset_links` 保存图片与笔记的引用关系。另一台设备收到 Vault 文件后,会在首次显示图片时校验路径哈希并重建本机元数据。
|
||||
|
||||
## 开发环境
|
||||
|
||||
| 工具 | 版本 |
|
||||
@@ -114,7 +124,22 @@ Gitea Actions 会在推送和合并请求时执行文档检查、后端测试、
|
||||
|
||||
## 部署 Sync Server
|
||||
|
||||
开发或内网验证可直接运行:
|
||||
推荐使用 Docker Compose 启动 PostgreSQL、MinIO 和 Sync:
|
||||
|
||||
```powershell
|
||||
cd "server sync"
|
||||
Copy-Item .env.example .env
|
||||
# 编辑 .env 并生成各项独立密钥
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
隔离测试阶段如需直接开放 `18080` 明文端口,可使用 Docker Compose 2.24.4 或更高版本加载测试覆盖文件:
|
||||
|
||||
```powershell
|
||||
docker compose -f compose.yaml -f compose.test.yaml up -d --build
|
||||
```
|
||||
|
||||
不使用容器的开发联调也可直接运行:
|
||||
|
||||
```powershell
|
||||
cd "server sync"
|
||||
@@ -124,6 +149,8 @@ uv run uvicorn sync_server.main:app --host 0.0.0.0 --port 18080
|
||||
|
||||
管理控制台构建后由 Sync Server 一并提供。正式环境应使用 PostgreSQL、S3 兼容对象存储、独立密钥、TLS 终止、进程守护和定期备份;完整变量与部署方式见 [`server sync/README.md`](server%20sync/README.md)。
|
||||
|
||||
新建 Sync 实例首次启动时会生成仅对本次启动有效的随机管理员密码。管理员首次登录后必须修改账户和密码;修改成功后凭据写入数据库,后续重启不再随机更换。升级已有实例会保留已固定的凭据、Vault、设备和修订记录。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
```text
|
||||
@@ -150,7 +177,7 @@ OpenNexus/
|
||||
|
||||
OpenNexus 将 Vault 内容、模型凭据和扩展权限视为敏感数据。请只安装可信来源的 Skill、Plugin 与主题包,并在授权前检查其权限。服务端部署不得使用示例密钥或开发数据库。
|
||||
|
||||
正式发行物通过 Git 标签追踪,并在发布页提供校验和。Windows 安装包的生产门禁还会验证 Authenticode 和 Core 清单签名。无法通过签名门禁的构建只能作为预发布测试包分发。
|
||||
正式发行物通过 Git 标签追踪,并在发布页提供校验和。Windows 安装包的生产门禁还会验证 Authenticode 和 Core 清单签名。本版 EXE 安装包尚未进行 Authenticode 签名,Windows 可能显示未知发布者提示。
|
||||
|
||||
## 参与开发
|
||||
|
||||
|
||||
@@ -81,6 +81,15 @@ class FolderDeleteRequest(Contract):
|
||||
path: str
|
||||
|
||||
|
||||
class WorkspaceAsset(Contract):
|
||||
asset_id: str
|
||||
path: str
|
||||
content_hash: str
|
||||
media_type: str
|
||||
size: int
|
||||
original_name: str
|
||||
|
||||
|
||||
# 笔记与检索
|
||||
class NoteBlock(Contract):
|
||||
block_id: str
|
||||
|
||||
@@ -172,6 +172,28 @@ MIGRATIONS: list[str] = [
|
||||
"""ALTER TABLE chat_messages ADD COLUMN workspace_context_json TEXT;""",
|
||||
"""ALTER TABLE chat_messages ADD COLUMN attachments_json TEXT NOT NULL DEFAULT '[]';""",
|
||||
"""ALTER TABLE chat_messages ADD COLUMN context_captured INTEGER NOT NULL DEFAULT 0;""",
|
||||
# v13:工作区图片本体保存在 Vault;数据库只保存可检索元数据和笔记引用关系。
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS workspace_assets (
|
||||
asset_id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL UNIQUE,
|
||||
media_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL CHECK(size >= 0),
|
||||
original_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS workspace_asset_links (
|
||||
asset_id TEXT NOT NULL REFERENCES workspace_assets(asset_id) ON DELETE CASCADE,
|
||||
note_id TEXT NOT NULL DEFAULT '',
|
||||
note_path TEXT NOT NULL,
|
||||
source TEXT NOT NULL CHECK(source IN ('paste', 'drop', 'upload', 'sync')),
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(asset_id, note_id, note_path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_asset_links_note
|
||||
ON workspace_asset_links(note_id, note_path);
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path],
|
||||
if written > MAX_EXPANDED_BYTES:
|
||||
raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 解压后不能超过 50 MiB。')
|
||||
output.write(chunk)
|
||||
if (entry.external_attr >> 16) & 0o111:
|
||||
target.chmod(0o755)
|
||||
manifest = f'{kind}.yaml'
|
||||
root = destination
|
||||
if not (root / manifest).is_file():
|
||||
|
||||
@@ -8,6 +8,23 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Worker 在发布包的临时挂载目录中运行,不能留下会触发 Core 完整性校验的字节码。
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
# 桌面 Host 只向 Core 传入最小环境。PyTorch 编译缓存会通过 getpass
|
||||
# 读取用户名;在 Windows 上缺少 USERNAME 时,它会误尝试导入 Unix 的 pwd。
|
||||
os.environ.setdefault(
|
||||
"USERNAME", os.path.basename(os.environ.get("USERPROFILE", "OpenNexus"))
|
||||
)
|
||||
os.environ.setdefault(
|
||||
"TORCHINDUCTOR_CACHE_DIR",
|
||||
os.path.join(
|
||||
os.environ.get("LOCALAPPDATA", os.environ.get("TEMP", ".")),
|
||||
"OpenNexus",
|
||||
"torchinductor",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def decode(path, *, limit_seconds=3600, warnings=None):
|
||||
import av
|
||||
|
||||
+34
-1
@@ -3,9 +3,10 @@ import json
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import aclosing
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Header, Query, Request
|
||||
from fastapi import APIRouter, Header, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
@@ -106,6 +107,7 @@ from app.contracts import (
|
||||
TranscriptionJob,
|
||||
TranscriptionRequest,
|
||||
WorkspaceEntry,
|
||||
WorkspaceAsset,
|
||||
WorkspaceInfo,
|
||||
WorkspaceOpenRequest,
|
||||
WorkspaceSnapshot,
|
||||
@@ -134,6 +136,7 @@ from app.services import (
|
||||
task_service,
|
||||
transcription_service,
|
||||
workspace_service,
|
||||
workspace_asset_service,
|
||||
)
|
||||
from app.services.attachment_service import attachment_path
|
||||
|
||||
@@ -258,6 +261,36 @@ async def delete_workspace_folder(request: FolderDeleteRequest) -> OperationResp
|
||||
return await workspace_service.delete_folder(request.path)
|
||||
|
||||
|
||||
@router.post("/workspace/assets", response_model=WorkspaceAsset, tags=["Workspace"])
|
||||
async def create_workspace_asset(
|
||||
request: Request,
|
||||
filename: str = Query(min_length=1, max_length=255),
|
||||
note_id: str = Query(default="", max_length=200),
|
||||
note_path: str = Query(min_length=1, max_length=2000),
|
||||
source: Literal["paste", "drop", "upload"] = Query(default="upload"),
|
||||
) -> WorkspaceAsset:
|
||||
content = bytearray()
|
||||
async for chunk in request.stream():
|
||||
content.extend(chunk)
|
||||
if len(content) > workspace_asset_service.MAX_IMAGE_BYTES:
|
||||
raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片不能超过 5 MiB。")
|
||||
result = workspace_asset_service.store(
|
||||
bytes(content), original_name=filename, note_id=note_id,
|
||||
note_path=note_path, source=source,
|
||||
)
|
||||
return WorkspaceAsset(**result)
|
||||
|
||||
|
||||
@router.get("/workspace/assets/content", tags=["Workspace"])
|
||||
async def get_workspace_asset_content(
|
||||
path: str = Query(min_length=1, max_length=500),
|
||||
note_id: str = Query(default="", max_length=200),
|
||||
note_path: str = Query(default="", max_length=2000),
|
||||
) -> Response:
|
||||
data, media_type = workspace_asset_service.read(path, note_id=note_id, note_path=note_path)
|
||||
return Response(data, media_type=media_type, headers={"Cache-Control": "private, max-age=31536000, immutable"})
|
||||
|
||||
|
||||
# 笔记
|
||||
@router.get("/notes", response_model=NoteListResponse, tags=["Notes"])
|
||||
async def list_notes(
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""工作区图片资产:原图归 Vault,SQLite 保存元数据与笔记引用。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from uuid import uuid4
|
||||
|
||||
from app import host_bridge
|
||||
from app.config import get_settings
|
||||
from app.database.db import connect_knowledge, transaction
|
||||
from app.errors import ApiError
|
||||
from app.services.vault_paths import resolve_in_vault
|
||||
|
||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def _image_kind(data: bytes) -> tuple[str, str]:
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "png", "image/png"
|
||||
if data.startswith(b"\xff\xd8\xff"):
|
||||
return "jpg", "image/jpeg"
|
||||
if data.startswith((b"GIF87a", b"GIF89a")):
|
||||
return "gif", "image/gif"
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "webp", "image/webp"
|
||||
raise ApiError(415, "WORKSPACE_IMAGE_UNSUPPORTED", "仅支持 PNG、JPEG、GIF 和 WebP 图片。")
|
||||
|
||||
|
||||
def _desktop() -> bool:
|
||||
return get_settings().environment == "desktop"
|
||||
|
||||
|
||||
def _vault_id() -> str:
|
||||
return host_bridge.vault_id.get() or "default"
|
||||
|
||||
|
||||
def _validate_asset_path(path: str) -> str:
|
||||
normalized = PurePosixPath(path.replace("\\", "/"))
|
||||
parts = normalized.parts
|
||||
if normalized.is_absolute() or ".." in parts or len(parts) != 3 or parts[0] != "attachments":
|
||||
raise ApiError(400, "INVALID_PATH", "图片路径不属于工作区附件目录。")
|
||||
return normalized.as_posix()
|
||||
|
||||
|
||||
def _write_web(path: str, data: bytes) -> None:
|
||||
target = resolve_in_vault(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
if target.read_bytes() != data:
|
||||
raise ApiError(409, "RESOURCE_CONFLICT", "附件路径已有不同内容。")
|
||||
return
|
||||
temporary = target.with_name(f".{target.name}.{uuid4().hex}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(data)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _write_desktop(path: str, data: bytes) -> None:
|
||||
if host_bridge.active is None:
|
||||
raise ApiError(503, "HOST_UNAVAILABLE", "桌面 Host 不可用。")
|
||||
try:
|
||||
host_bridge.active.call(
|
||||
"workspace.assets.write", vault_id=_vault_id(), path=path,
|
||||
content_base64=base64.b64encode(data).decode("ascii"), operation_id=str(uuid4()),
|
||||
)
|
||||
except RuntimeError as error:
|
||||
raise ApiError(409 if str(error) == "REVISION_CONFLICT" else 503,
|
||||
str(error), "写入工作区图片失败。") from None
|
||||
|
||||
|
||||
def _record(*, digest: str, path: str, media_type: str, size: int, original_name: str,
|
||||
note_id: str, note_path: str, source: str) -> None:
|
||||
asset_id = f"asset_{digest}"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = connect_knowledge()
|
||||
try:
|
||||
with transaction(conn):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_assets(asset_id,path,content_hash,media_type,size,original_name,created_at) VALUES(?,?,?,?,?,?,?)",
|
||||
(asset_id, path, digest, media_type, size, Path(original_name).name[:255], now),
|
||||
)
|
||||
if note_path:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_asset_links(asset_id,note_id,note_path,source,created_at) VALUES(?,?,?,?,?)",
|
||||
(asset_id, note_id, note_path.replace("\\", "/").lstrip("/"), source, now),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def store(data: bytes, *, original_name: str, note_id: str, note_path: str, source: str) -> dict:
|
||||
if not data:
|
||||
raise ApiError(400, "WORKSPACE_IMAGE_EMPTY", "图片内容为空。")
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片不能超过 5 MiB。")
|
||||
if source not in {"paste", "drop", "upload"}:
|
||||
raise ApiError(400, "WORKSPACE_IMAGE_SOURCE_INVALID", "图片来源无效。")
|
||||
extension, media_type = _image_kind(data)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
asset_id = f"asset_{digest}"
|
||||
path = f"attachments/{digest[:2]}/{digest}.{extension}"
|
||||
(_write_desktop if _desktop() else _write_web)(path, data)
|
||||
|
||||
_record(digest=digest, path=path, media_type=media_type, size=len(data),
|
||||
original_name=Path(original_name).name or f"image.{extension}", note_id=note_id,
|
||||
note_path=note_path, source=source)
|
||||
return {"asset_id": asset_id, "path": path, "content_hash": digest,
|
||||
"media_type": media_type, "size": len(data), "original_name": Path(original_name).name}
|
||||
|
||||
|
||||
def read(path: str, *, note_id: str = "", note_path: str = "") -> tuple[bytes, str]:
|
||||
path = _validate_asset_path(path)
|
||||
if _desktop():
|
||||
if host_bridge.active is None:
|
||||
raise ApiError(503, "HOST_UNAVAILABLE", "桌面 Host 不可用。")
|
||||
try:
|
||||
result = host_bridge.active.call("workspace.assets.read", vault_id=_vault_id(), path=path)
|
||||
data = base64.b64decode(result["content_base64"], validate=True)
|
||||
except (RuntimeError, KeyError, ValueError):
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "工作区图片不存在。") from None
|
||||
else:
|
||||
target = resolve_in_vault(path)
|
||||
if not target.is_file() or target.is_symlink():
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "工作区图片不存在。")
|
||||
data = target.read_bytes()
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片超过读取上限。")
|
||||
_, media_type = _image_kind(data)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
expected = PurePosixPath(path).stem
|
||||
if digest != expected:
|
||||
raise ApiError(409, "WORKSPACE_IMAGE_HASH_MISMATCH", "工作区图片内容与路径哈希不一致。")
|
||||
_record(digest=digest, path=path, media_type=media_type, size=len(data),
|
||||
original_name=PurePosixPath(path).name, note_id=note_id, note_path=note_path,
|
||||
source="sync")
|
||||
return data, media_type
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -24,12 +25,15 @@ def build(output: Path | None = None) -> dict:
|
||||
if identity == 'markdown-workbench':
|
||||
with tempfile.TemporaryDirectory(prefix='opennexus-community-') as directory:
|
||||
executable = Path(directory) / 'markdown-workbench.exe'
|
||||
subprocess.run([
|
||||
rustc_command = [
|
||||
'rustc', '--edition=2021', '--crate-name', 'markdown_workbench',
|
||||
'-C', 'metadata=opennexus-community-v1', '-C', 'opt-level=s',
|
||||
'-C', 'strip=symbols', '-C', 'link-arg=-Wl,--no-insert-timestamp',
|
||||
str(source / 'server.rs'), '-o', str(executable),
|
||||
], check=True)
|
||||
'-C', 'strip=symbols',
|
||||
]
|
||||
if sys.platform == 'win32':
|
||||
rustc_command.extend(['-C', 'link-arg=-Wl,--no-insert-timestamp'])
|
||||
rustc_command.extend([str(source / 'server.rs'), '-o', str(executable)])
|
||||
subprocess.run(rustc_command, check=True)
|
||||
generated[executable.name] = executable.read_bytes()
|
||||
manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8')
|
||||
version = re.search(r'^version: (\d+\.\d+\.\d+)$', manifest, re.M)[1]
|
||||
@@ -38,7 +42,7 @@ def build(output: Path | None = None) -> dict:
|
||||
for name in sorted(files):
|
||||
info = zipfile.ZipInfo(f'{identity}/{name}', date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.create_system = 3
|
||||
info.external_attr = 0o100644 << 16
|
||||
info.external_attr = (0o100755 if name == 'markdown-workbench.exe' else 0o100644) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
content = generated.get(name)
|
||||
if content is None:
|
||||
|
||||
@@ -112,6 +112,8 @@ def test_workspace_openapi_paths_are_published() -> None:
|
||||
"/api/workspace/folders",
|
||||
"/api/workspace/folders/rename",
|
||||
"/api/workspace/folders/delete",
|
||||
"/api/workspace/assets",
|
||||
"/api/workspace/assets/content",
|
||||
"/api/notes/{note_id}/rename",
|
||||
} <= paths.keys()
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database.db import connect
|
||||
from app.main import app
|
||||
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"fixture-image"
|
||||
|
||||
|
||||
def test_workspace_image_is_content_addressed_and_linked() -> None:
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/workspace/assets",
|
||||
params={"filename": "截图.png", "note_id": "note-1", "note_path": "课程/笔记.md", "source": "paste"},
|
||||
content=PNG,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
asset = response.json()
|
||||
target = get_settings().vault_path / asset["path"]
|
||||
assert target.read_bytes() == PNG
|
||||
assert asset["path"].startswith("attachments/")
|
||||
|
||||
content = client.get("/api/workspace/assets/content", params={"path": asset["path"]})
|
||||
assert content.status_code == 200
|
||||
assert content.content == PNG
|
||||
assert content.headers["content-type"] == "image/png"
|
||||
|
||||
duplicate = client.post(
|
||||
"/api/workspace/assets",
|
||||
params={"filename": "same.png", "note_id": "note-2", "note_path": "另一篇.md", "source": "upload"},
|
||||
content=PNG,
|
||||
)
|
||||
assert duplicate.json()["asset_id"] == asset["asset_id"]
|
||||
conn = connect()
|
||||
try:
|
||||
assert conn.execute("SELECT count(*) FROM workspace_assets").fetchone()[0] == 1
|
||||
assert conn.execute("SELECT count(*) FROM workspace_asset_links").fetchone()[0] == 2
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 模拟另一台设备只同步 Vault 文件;读取时会重建本机派生元数据。
|
||||
conn = connect()
|
||||
try:
|
||||
conn.execute("DELETE FROM workspace_asset_links")
|
||||
conn.execute("DELETE FROM workspace_assets")
|
||||
finally:
|
||||
conn.close()
|
||||
restored = client.get(
|
||||
"/api/workspace/assets/content",
|
||||
params={"path": asset["path"], "note_id": "synced-note", "note_path": "同步/笔记.md"},
|
||||
)
|
||||
assert restored.status_code == 200
|
||||
conn = connect()
|
||||
try:
|
||||
assert conn.execute("SELECT source FROM workspace_asset_links").fetchone()[0] == "sync"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_workspace_image_rejects_unknown_content_and_traversal() -> None:
|
||||
with TestClient(app) as client:
|
||||
unsupported = client.post(
|
||||
"/api/workspace/assets",
|
||||
params={"filename": "fake.png", "note_path": "笔记.md", "source": "upload"},
|
||||
content=b"not an image",
|
||||
)
|
||||
assert unsupported.status_code == 415
|
||||
traversal = client.get("/api/workspace/assets/content", params={"path": "../secret.png"})
|
||||
assert traversal.status_code == 400
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "notes-agent-frontend",
|
||||
"private": true,
|
||||
"version": "0.3.0-alpha.1",
|
||||
"version": "0.3.1-alpha.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"desktop:build": "tauri build --no-bundle --features desktop",
|
||||
"desktop:build": "tauri build --features desktop --bundles nsis --config src-tauri/tauri.bundle.conf.json",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json && vue-tsc --noEmit -p tsconfig.node.json",
|
||||
|
||||
Generated
+1
-1
@@ -3242,7 +3242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notesagent-desktop"
|
||||
version = "0.3.0-alpha.1"
|
||||
version = "0.3.1-alpha.3"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "notesagent-desktop"
|
||||
version = "0.3.0-alpha.1"
|
||||
version = "0.3.1-alpha.3"
|
||||
edition = "2021"
|
||||
rust-version = "1.89"
|
||||
|
||||
@@ -14,7 +14,7 @@ required-features = ["desktop"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:base64", "dep:tokio"]
|
||||
desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:tokio"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -27,7 +27,7 @@ fs2 = "0.4"
|
||||
tauri = { version = "2", optional = true, features = ["tray-icon"] }
|
||||
rfd = { version = "0.15", optional = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true }
|
||||
base64 = { version = "0.22", optional = true }
|
||||
base64 = "0.22"
|
||||
tokio = { version = "1", features = ["rt", "sync", "time", "macros"], optional = true }
|
||||
hmac = { version = "0.12", default-features = false }
|
||||
rand = { version = "0.8", default-features = false, features = ["getrandom"] }
|
||||
|
||||
@@ -35,6 +35,7 @@ fn main() {
|
||||
"sync_unbind",
|
||||
"sync_pause",
|
||||
"sync_status",
|
||||
"sync_set_scope",
|
||||
"sync_resolve",
|
||||
"sync_logout",
|
||||
"sync_run",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-sync-set-scope"
|
||||
description = "Enables the sync_set_scope command without any pre-configured scope."
|
||||
commands.allow = ["sync_set_scope"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-sync-set-scope"
|
||||
description = "Denies the sync_set_scope command without any pre-configured scope."
|
||||
commands.deny = ["sync_set_scope"]
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 受限的 Core RPC;每个请求都绑定到 Host 传输捕获的 Vault。
|
||||
use crate::workspace::Workspace;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -27,6 +28,47 @@ struct Write {
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AssetWrite {
|
||||
vault_id: String,
|
||||
path: String,
|
||||
content_base64: String,
|
||||
operation_id: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AssetRead {
|
||||
vault_id: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
fn valid_asset_path(path: &str) -> bool {
|
||||
let normalized = path.replace('\\', "/");
|
||||
let parts: Vec<_> = normalized.split('/').collect();
|
||||
parts.len() == 3
|
||||
&& parts[0] == "attachments"
|
||||
&& parts[1].len() == 2
|
||||
&& !parts
|
||||
.iter()
|
||||
.any(|part| part.is_empty() || *part == "." || *part == "..")
|
||||
&& ["png", "jpg", "gif", "webp"]
|
||||
.iter()
|
||||
.any(|suffix| normalized.ends_with(&format!(".{suffix}")))
|
||||
}
|
||||
|
||||
fn valid_image_bytes(path: &str, bytes: &[u8]) -> bool {
|
||||
let extension = path.rsplit('.').next().unwrap_or("");
|
||||
match extension {
|
||||
"png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
|
||||
"jpg" => bytes.starts_with(b"\xff\xd8\xff"),
|
||||
"gif" => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"),
|
||||
"webp" => bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Operation {
|
||||
vault_id: String,
|
||||
operation_id: String,
|
||||
@@ -253,6 +295,57 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||
.map_err(|e| e.code)?;
|
||||
Ok(json!({"operation_id":p.operation_id,"state":"committed","result":entry}))
|
||||
}
|
||||
"workspace.assets.write" => {
|
||||
let p: AssetWrite = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
if !valid_asset_path(&p.path) {
|
||||
return Err("WORKSPACE_REQUEST_INVALID".into());
|
||||
}
|
||||
let bytes = STANDARD
|
||||
.decode(&p.content_base64)
|
||||
.map_err(|_| "WORKSPACE_REQUEST_INVALID")?;
|
||||
if bytes.is_empty()
|
||||
|| bytes.len() > MAX_IMAGE_BYTES
|
||||
|| !valid_image_bytes(&p.path, &bytes)
|
||||
|| crate::workspace::hash(&bytes)
|
||||
!= p.path
|
||||
.split('/')
|
||||
.last()
|
||||
.unwrap_or("")
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
{
|
||||
return Err("WORKSPACE_REQUEST_INVALID".into());
|
||||
}
|
||||
let target = ws.resolve(&p.path).map_err(|e| e.code)?;
|
||||
if target.exists() {
|
||||
let existing = std::fs::read(target).map_err(|_| "FILESYSTEM_ERROR")?;
|
||||
if existing != bytes {
|
||||
return Err("REVISION_CONFLICT".into());
|
||||
}
|
||||
return Ok(
|
||||
json!({"path":p.path,"hash":crate::workspace::hash(&bytes),"size":bytes.len()}),
|
||||
);
|
||||
}
|
||||
let entry = ws
|
||||
.write_operation(&p.path, "", &bytes, "local", &p.operation_id)
|
||||
.map_err(|e| e.code)?;
|
||||
Ok(json!({"path":entry.path,"hash":entry.hash,"size":bytes.len()}))
|
||||
}
|
||||
"workspace.assets.read" => {
|
||||
let p: AssetRead = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
if !valid_asset_path(&p.path) {
|
||||
return Err("WORKSPACE_REQUEST_INVALID".into());
|
||||
}
|
||||
let bytes = std::fs::read(ws.resolve(&p.path).map_err(|e| e.code)?)
|
||||
.map_err(|_| "FILE_NOT_FOUND")?;
|
||||
if bytes.len() > MAX_IMAGE_BYTES {
|
||||
return Err("CORE_NOTE_TOO_LARGE".into());
|
||||
}
|
||||
Ok(json!({"content_base64":STANDARD.encode(bytes)}))
|
||||
}
|
||||
"workspace.operation" => {
|
||||
let p: Operation = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
@@ -285,6 +378,39 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn workspace_images_are_binary_content_addressed_and_vault_bound() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let bytes = b"\x89PNG\r\n\x1a\nfixture";
|
||||
let digest = crate::workspace::hash(bytes);
|
||||
let path = format!("attachments/{}/{}.png", &digest[..2], digest);
|
||||
let write = json!({"rpc":"workspace.assets.write","params":{
|
||||
"vault_id":ws.vault_id,"path":path,"content_base64":STANDARD.encode(bytes),
|
||||
"operation_id":uuid::Uuid::new_v4().to_string()
|
||||
}});
|
||||
let stored = dispatch(&mut ws, &write).unwrap();
|
||||
assert_eq!(stored["hash"], digest);
|
||||
assert_eq!(dispatch(&mut ws, &write).unwrap()["hash"], digest);
|
||||
let read =
|
||||
json!({"rpc":"workspace.assets.read","params":{"vault_id":ws.vault_id,"path":path}});
|
||||
assert_eq!(
|
||||
STANDARD
|
||||
.decode(
|
||||
dispatch(&mut ws, &read).unwrap()["content_base64"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
bytes
|
||||
);
|
||||
let mut denied = write;
|
||||
denied["params"]["vault_id"] = json!("other-vault");
|
||||
assert_eq!(
|
||||
dispatch(&mut ws, &denied).unwrap_err(),
|
||||
"VAULT_PERMISSION_CHANGED"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn user_skills_are_vault_bound_listed_and_deleted_as_logical_records() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"resources": {"../../.build/sidecar/dist/opennexus-core/": "core/"}
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"publisher": "Kronecker",
|
||||
"homepage": "https://gitea.kronecker.cc/Kronecker/NotesAgentic",
|
||||
"icon": [
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"../../.build/sidecar/dist/opennexus-core/": "core/"
|
||||
},
|
||||
"category": "Productivity",
|
||||
"shortDescription": "本地优先的 AI 笔记与知识中枢",
|
||||
"windows": {
|
||||
"allowDowngrades": false,
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper",
|
||||
"silent": true
|
||||
},
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"languages": [
|
||||
"SimpChinese",
|
||||
"English"
|
||||
],
|
||||
"displayLanguageSelector": false,
|
||||
"compression": "lzma",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"uninstallerIcon": "icons/icon.ico",
|
||||
"startMenuFolder": "OpenNexus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenNexus",
|
||||
"version": "0.3.0-alpha.1",
|
||||
"version": "0.3.1-alpha.3",
|
||||
"identifier": "cc.kronecker.notesagent",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -10,11 +10,26 @@
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{"label": "main", "title": "OpenNexus", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}],
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "OpenNexus",
|
||||
"width": 1280,
|
||||
"height": 960,
|
||||
"minWidth": 720,
|
||||
"minHeight": 700,
|
||||
"center": true,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"capabilities": ["main"]
|
||||
"capabilities": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {"active": false}
|
||||
"bundle": {
|
||||
"active": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +47,16 @@ it('冲突文档禁用命令,保持原始内容', async () => {
|
||||
expect(await executeEditorCommand('editor.import-note-properties')).toMatchObject({ ok: false, reason: 'unavailable' })
|
||||
expect(store.content).toBe(original)
|
||||
})
|
||||
|
||||
it('选择图片后写入工作区并插入相对 Markdown 引用', async () => {
|
||||
vi.spyOn(workspace, 'storeWorkspaceImage').mockResolvedValue({
|
||||
asset_id: 'asset-fixture', path: 'attachments/aa/hash.png', content_hash: 'hash',
|
||||
media_type: 'image/png', size: 12, original_name: '截图.png', reference: 'attachments/aa/hash.png',
|
||||
})
|
||||
const input = wrapper!.get('input[type="file"]')
|
||||
const file = new File(['png'], '截图.png', { type: 'image/png' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
await input.trigger('change')
|
||||
await vi.waitFor(() => expect(useEditorStore().content).toContain(''))
|
||||
expect(workspace.storeWorkspaceImage).toHaveBeenCalledWith(file, 'upload', '/fixture.md', 'note-fixture')
|
||||
})
|
||||
|
||||
@@ -9,10 +9,12 @@ import { useSettingsStore } from '@/stores/settings'
|
||||
import { registerEditorCommands } from '@/services/editorCommandService'
|
||||
import { previewPropertyImport, type PropertyChoices, type PropertyConflict } from './importProperties'
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
import { storeWorkspaceImage, type WorkspaceAssetSource } from '@/services/workspaceService'
|
||||
|
||||
const props = defineProps<{ initialContent: string }>()
|
||||
const editor = useEditorStore(), settings = useSettingsStore()
|
||||
const root = ref<HTMLElement | null>(null), error = ref('')
|
||||
const imageInput = ref<HTMLInputElement | null>(null)
|
||||
const conflicts = ref<PropertyConflict[]>([]), choices = ref<PropertyChoices>({})
|
||||
const proofing = new Compartment()
|
||||
let view: EditorView | undefined, dispose: (() => void) | undefined
|
||||
@@ -22,6 +24,31 @@ function attributes() {
|
||||
'aria-label': settings.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器' })
|
||||
}
|
||||
function available() { return !!view && !!editor.currentFilePath && !['conflict', 'external_changed'].includes(editor.saveStatus) }
|
||||
function imageFiles(list: FileList | null): File[] {
|
||||
return [...(list ?? [])].filter(file => file.type.startsWith('image/'))
|
||||
}
|
||||
async function insertImages(files: File[], source: WorkspaceAssetSource, position?: number) {
|
||||
if (!view || !available() || !files.length) return
|
||||
const targetView = view, targetPath = editor.currentFilePath
|
||||
const at = position ?? targetView.state.selection.main.from
|
||||
const document = targetView.state.doc
|
||||
error.value = ''
|
||||
try {
|
||||
const assets = []
|
||||
for (const file of files) assets.push(await storeWorkspaceImage(file, source, targetPath!, editor.currentNoteId))
|
||||
if (view !== targetView || editor.currentFilePath !== targetPath || !available()) return
|
||||
const markdown = assets.map(asset => `![${asset.original_name.replace(/[\]\\]/g, '\\$&')}](${asset.reference})`).join('\n\n')
|
||||
const insertion = targetView.state.doc.eq(document) ? Math.min(at, targetView.state.doc.length) : targetView.state.selection.main.from
|
||||
targetView.dispatch({ changes: { from: insertion, insert: markdown } })
|
||||
targetView.focus()
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : String(reason) }
|
||||
}
|
||||
function chooseImages() { imageInput.value?.click() }
|
||||
function selectedImages(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
void insertImages(imageFiles(input.files), 'upload')
|
||||
input.value = ''
|
||||
}
|
||||
function importProperties() {
|
||||
if (!available() || !view) return { ok: false as const, reason: 'unavailable' as const }
|
||||
error.value = ''; choices.value = {}
|
||||
@@ -57,6 +84,20 @@ onMounted(() => {
|
||||
view = new EditorView({ parent: root.value!, state: EditorState.create({ doc: props.initialContent, extensions: [
|
||||
history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), markdown(), proofing.of(attributes()),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.domEventHandlers({
|
||||
paste(event) {
|
||||
const files = imageFiles(event.clipboardData?.files ?? null)
|
||||
if (!files.length) return false
|
||||
event.preventDefault(); void insertImages(files, 'paste'); return true
|
||||
},
|
||||
drop(event, currentView) {
|
||||
const files = imageFiles(event.dataTransfer?.files ?? null)
|
||||
if (!files.length) return false
|
||||
event.preventDefault()
|
||||
const position = currentView.posAtCoords({ x: event.clientX, y: event.clientY }) ?? currentView.state.selection.main.from
|
||||
void insertImages(files, 'drop', position); return true
|
||||
},
|
||||
}),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) { editor.updateContent(update.state.doc.toString()); editor.scheduleAutoSave(settings.autoSaveInterval) }
|
||||
}),
|
||||
@@ -82,7 +123,11 @@ onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })
|
||||
|
||||
<template>
|
||||
<div class="source-container">
|
||||
<div class="source-actions"><button class="btn" :disabled="!editor.currentFilePath || ['conflict', 'external_changed'].includes(editor.saveStatus)" @click="importProperties">导入为笔记属性…</button></div>
|
||||
<div class="source-actions">
|
||||
<button class="btn" :disabled="!editor.currentFilePath || ['conflict', 'external_changed'].includes(editor.saveStatus)" @click="importProperties">导入为笔记属性…</button>
|
||||
<button class="btn" :disabled="!available()" @click="chooseImages">插入图片…</button>
|
||||
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple @change="selectedImages" />
|
||||
</div>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<div ref="root" class="source-code" />
|
||||
<AppDialog v-if="conflicts.length" label="属性冲突预览" @close="conflicts = []; pending = undefined">
|
||||
@@ -100,6 +145,7 @@ onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })
|
||||
<style scoped>
|
||||
.source-container { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.source-code { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.source-actions { padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.source-actions { display: flex; gap: var(--space-sm); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useEditorStore } from '@/stores/editor'
|
||||
import { executeEditorCommand } from '@/services/editorCommandService'
|
||||
import { headingFoldKey } from './headingFolding'
|
||||
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
|
||||
import * as workspace from '@/services/workspaceService'
|
||||
|
||||
type EditorComponent = { getEditor: () => Editor | undefined }
|
||||
|
||||
@@ -52,9 +53,26 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
mounted.splice(0).forEach((wrapper) => wrapper.unmount())
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
it('uploads a selected image and keeps a portable Markdown reference', async () => {
|
||||
const store = useEditorStore(); store.currentFilePath = '/课程/笔记.md'; store.currentNoteId = 'note-image'
|
||||
vi.spyOn(workspace, 'storeWorkspaceImage').mockResolvedValue({
|
||||
asset_id: 'asset-image', path: 'attachments/aa/hash.png', content_hash: 'hash',
|
||||
media_type: 'image/png', size: 12, original_name: '图.png', reference: '../attachments/aa/hash.png',
|
||||
})
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '' }, attachTo: document.body })
|
||||
mounted.push(wrapper); const editor = await waitForEditor(wrapper)
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
const file = new File(['png'], '图.png', { type: 'image/png' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
await input.trigger('change')
|
||||
await vi.waitFor(() => expect(editor.action(getMarkdown())).toContain(''))
|
||||
expect(workspace.storeWorkspaceImage).toHaveBeenCalledWith(file, 'upload', '/课程/笔记.md', 'note-image')
|
||||
})
|
||||
|
||||
it('opens a rendered Markdown link on Ctrl click without changing its source', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {
|
||||
props: { initialContent: '[**文档**](https://example.com/docs)' }, attachTo: document.body,
|
||||
|
||||
@@ -50,6 +50,7 @@ import { headingFoldingPlugin, headingFoldTransaction, headingFoldKey, headingSe
|
||||
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
|
||||
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
|
||||
import { t } from '@/i18n'
|
||||
import { loadWorkspaceImage, resolveWorkspaceAssetPath, storeWorkspaceImage, type WorkspaceAssetSource } from '@/services/workspaceService'
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
|
||||
@@ -80,23 +81,92 @@ const loading = ref(true)
|
||||
const allHeadingsFolded = ref(false)
|
||||
const hasFoldableHeadings = ref(false)
|
||||
const fontSizeInput = ref(16)
|
||||
const imageInput = ref<HTMLInputElement | null>(null)
|
||||
const imageError = ref('')
|
||||
let crepe: Crepe | null = null
|
||||
let disposeLanguagePicker: (() => void) | undefined
|
||||
let disposeCodeLabels: (() => void) | undefined
|
||||
let disposeLinkNavigation: (() => void) | undefined
|
||||
let disposeCommands: (() => void) | undefined
|
||||
let disposed = false
|
||||
const imageUrls = new Set<string>()
|
||||
|
||||
function insertMarkdown(source: string) {
|
||||
function insertMarkdown(source: string, position?: number) {
|
||||
crepe?.editor.action(ctx => {
|
||||
const doc = ctx.get(parserCtx)(source)
|
||||
if (!doc) throw new Error('Invalid Markdown')
|
||||
const view = ctx.get(editorViewCtx)
|
||||
if (position !== undefined) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, Math.min(position, view.state.doc.content.size))))
|
||||
view.dispatch(view.state.tr.replaceSelection(new Slice(doc.content, 0, 0)).scrollIntoView())
|
||||
view.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function imageFiles(list: FileList | null): File[] {
|
||||
return [...(list ?? [])].filter(file => file.type.startsWith('image/'))
|
||||
}
|
||||
|
||||
async function insertImages(files: File[], source: WorkspaceAssetSource, position?: number) {
|
||||
if (!crepe || !editorStore.currentFilePath || !files.length) return
|
||||
const target = crepe, targetPath = editorStore.currentFilePath
|
||||
const document = target.editor.action(ctx => ctx.get(editorViewCtx).state.doc)
|
||||
imageError.value = ''
|
||||
try {
|
||||
const assets = []
|
||||
for (const file of files) assets.push(await storeWorkspaceImage(file, source, targetPath, editorStore.currentNoteId))
|
||||
if (crepe !== target || editorStore.currentFilePath !== targetPath) return
|
||||
const current = target.editor.action(ctx => ctx.get(editorViewCtx).state.doc)
|
||||
insertMarkdown(assets.map(asset => `![${asset.original_name.replace(/[\]\\]/g, '\\$&')}](${asset.reference})`).join('\n\n'), current.eq(document) ? position : undefined)
|
||||
} catch (reason) {
|
||||
imageError.value = reason instanceof Error ? reason.message : String(reason)
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImages() { imageInput.value?.click() }
|
||||
function selectedImages(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
void insertImages(imageFiles(input.files), 'upload')
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function workspaceImageNodeView(node: { type: unknown; attrs: Record<string, unknown> }) {
|
||||
const notePath = editorStore.currentFilePath
|
||||
const dom = document.createElement('img')
|
||||
let source = '', objectUrl = '', generation = 0
|
||||
const apply = (next: typeof node) => {
|
||||
const nextSource = String(next.attrs.src ?? '')
|
||||
dom.alt = String(next.attrs.alt ?? '')
|
||||
if (next.attrs.title) dom.title = String(next.attrs.title)
|
||||
else dom.removeAttribute('title')
|
||||
if (nextSource === source) return
|
||||
source = nextSource
|
||||
const current = ++generation
|
||||
if (objectUrl) { URL.revokeObjectURL(objectUrl); imageUrls.delete(objectUrl); objectUrl = '' }
|
||||
const path = notePath && resolveWorkspaceAssetPath(notePath, nextSource)
|
||||
if (!path) { dom.src = nextSource; return }
|
||||
dom.dataset.workspaceAsset = path
|
||||
void loadWorkspaceImage(path, notePath, editorStore.currentNoteId).then(blob => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
if (disposed || current !== generation) { URL.revokeObjectURL(url); return }
|
||||
objectUrl = url; imageUrls.add(url); dom.src = url
|
||||
}).catch(() => {
|
||||
if (current === generation) dom.dataset.workspaceAssetError = 'true'
|
||||
})
|
||||
}
|
||||
apply(node)
|
||||
return {
|
||||
dom,
|
||||
update(next: typeof node) {
|
||||
if (next.type !== node.type) return false
|
||||
node = next; apply(next); return true
|
||||
},
|
||||
destroy() {
|
||||
generation++
|
||||
if (objectUrl) { URL.revokeObjectURL(objectUrl); imageUrls.delete(objectUrl) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertCallout(event: Event) {
|
||||
const select = event.target as HTMLSelectElement
|
||||
if (select.value) insertMarkdown(`> [!${select.value.toUpperCase()}]\n> ${t('提示内容', 'Callout content')}`)
|
||||
@@ -293,7 +363,12 @@ onMounted(async () => {
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
defaultValue: metadata.value?.body ?? props.initialContent,
|
||||
features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math },
|
||||
// 使用标准 Markdown 图片节点,确保 alt 文本和相对路径可被其他编辑器直接读取。
|
||||
features: {
|
||||
[Crepe.Feature.TopBar]: false,
|
||||
[Crepe.Feature.Latex]: markdownPreferences.math,
|
||||
[Crepe.Feature.ImageBlock]: false,
|
||||
},
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
|
||||
[Crepe.Feature.CodeMirror]: {
|
||||
@@ -385,6 +460,23 @@ onMounted(async () => {
|
||||
crepe.editor.use(inlineCodeInputPlugin)
|
||||
if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin)
|
||||
crepe.editor.use(headingFoldingPlugin)
|
||||
crepe.editor.use($prose(() => new Plugin({
|
||||
props: {
|
||||
nodeViews: { image: workspaceImageNodeView },
|
||||
handlePaste(_view, event) {
|
||||
const files = imageFiles(event.clipboardData?.files ?? null)
|
||||
if (!files.length) return false
|
||||
event.preventDefault(); void insertImages(files, 'paste'); return true
|
||||
},
|
||||
handleDrop(view, event) {
|
||||
const files = imageFiles(event.dataTransfer?.files ?? null)
|
||||
if (!files.length) return false
|
||||
event.preventDefault()
|
||||
const position = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos ?? view.state.selection.from
|
||||
void insertImages(files, 'drop', position); return true
|
||||
},
|
||||
},
|
||||
})))
|
||||
crepe.editor.use($prose(() => new Plugin({
|
||||
view(view) {
|
||||
const sync = (current: typeof view) => {
|
||||
@@ -438,7 +530,7 @@ watch(() => editorStore.headingRequest, request => {
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeLinkNavigation?.(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
|
||||
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); imageUrls.forEach(URL.revokeObjectURL); imageUrls.clear(); disposeLinkNavigation?.(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
|
||||
|
||||
defineExpose({ getEditor: () => crepe?.editor })
|
||||
</script>
|
||||
@@ -446,6 +538,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
<template>
|
||||
<DiagramInteractions class="visual-editor" :class="{ 'hide-code-line-numbers': !markdownPreferences.lineNumbers }" :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables">
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<p v-if="imageError" class="image-error" role="alert">{{ imageError }}</p>
|
||||
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
|
||||
<div class="section-actions">
|
||||
<button type="button" :disabled="loading || !hasFoldableHeadings"
|
||||
@@ -489,6 +582,8 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
<button v-if="markdownPreferences.math" type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
|
||||
<button v-if="markdownPreferences.math" type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph">∑</span></button>
|
||||
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
|
||||
<button type="button" :title="t('插入工作区图片', 'Insert workspace image')" :aria-label="t('插入工作区图片', 'Insert workspace image')" @pointerdown.prevent="chooseImages"><span class="image-glyph">▧</span></button>
|
||||
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple @change="selectedImages" />
|
||||
<label class="toolbar-select">
|
||||
<select v-if="markdownPreferences.callouts" :aria-label="t('插入警告框', 'Insert callout')" @change="insertCallout">
|
||||
<option value="">{{ t('提示框', 'Callout') }}</option>
|
||||
@@ -514,6 +609,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
|
||||
<style scoped>
|
||||
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
|
||||
.image-error { margin: 0; padding: var(--space-sm) var(--space-lg); color: var(--color-error); background: var(--color-error-soft); }
|
||||
.hide-code-line-numbers :deep(.cm-lineNumbers) { display: none; }
|
||||
.markdown-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 42px; padding: 5px var(--space-lg); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-primary); }
|
||||
.markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
|
||||
@@ -532,6 +628,8 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
.list-lines { overflow: hidden; width: 14px; font-size: 15px; line-height: 1; transform: scaleX(1.2); }
|
||||
.code-glyph, .block-glyph { padding: 0; background: transparent; color: inherit; font: 700 13px/1 var(--font-editor-mono); }
|
||||
.math-glyph { font: italic 700 16px/1 Georgia, 'Times New Roman', serif; }
|
||||
.image-glyph { font-size: 18px; line-height: 1; }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
.toolbar-select { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 5px 3px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
|
||||
.toolbar-select select { min-width: 58px; border: 0; outline: 0; background: transparent; color: inherit; cursor: pointer; font-size: var(--font-size-sm); }
|
||||
.font-size-select select { min-width: 62px; }
|
||||
|
||||
@@ -158,8 +158,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }) {
|
||||
return request<T>(path, { method: 'POST', body, headers })
|
||||
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }, params?: RequestOptions['params']) {
|
||||
return request<T>(path, { method: 'POST', body, headers, params })
|
||||
},
|
||||
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
|
||||
return request<T>(path, { ...options, method: 'GET' })
|
||||
|
||||
@@ -6,7 +6,7 @@ import paper from '@/assets/themes/paper-moments.theme?raw'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
it('uses the desktop release version for compatibility checks', () => {
|
||||
expect(THEME_APP_VERSION).toBe('0.3.0-alpha.1')
|
||||
expect(THEME_APP_VERSION).toBe('0.3.1-alpha.2')
|
||||
})
|
||||
it.each(['999.0.0', 'bad', '0.2'])('rejects unsupported minimum app version %s at inspection and install', async version => {
|
||||
const source = paper.replace(/min_app_version:.*\r?\n/, `min_app_version: ${version}\n`)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveWorkspaceAssetPath, workspaceAssetReference } from './workspaceService'
|
||||
|
||||
describe('workspace asset paths', () => {
|
||||
it('creates portable references relative to the note', () => {
|
||||
expect(workspaceAssetReference('/课程/系统/调度.md', 'attachments/ab/hash.png')).toBe('../../attachments/ab/hash.png')
|
||||
expect(resolveWorkspaceAssetPath('/课程/系统/调度.md', '../../attachments/ab/hash.png')).toBe('attachments/ab/hash.png')
|
||||
})
|
||||
|
||||
it('does not resolve remote URLs or paths escaping the vault', () => {
|
||||
expect(resolveWorkspaceAssetPath('/a.md', 'https://example.com/image.png')).toBeNull()
|
||||
expect(resolveWorkspaceAssetPath('/a.md', '../attachments/ab/hash.png')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,17 @@ import * as noteService from './noteService'
|
||||
import { splitNoteMetadata } from '@/utils/noteMetadata'
|
||||
import { contentHash, hostInvoke, isDesktop, nativePath, nativeTree, type HostDocument, type HostEntry, type HostVault } from './platform/desktop'
|
||||
|
||||
export interface WorkspaceAsset {
|
||||
asset_id: string
|
||||
path: string
|
||||
content_hash: string
|
||||
media_type: string
|
||||
size: number
|
||||
original_name: string
|
||||
}
|
||||
|
||||
export type WorkspaceAssetSource = 'paste' | 'drop' | 'upload'
|
||||
|
||||
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
||||
export interface VaultInfo {
|
||||
vault_id: string
|
||||
@@ -147,6 +158,41 @@ export async function readFileContent(filePath: string): Promise<string> {
|
||||
return note.markdown
|
||||
}
|
||||
|
||||
/** 将 Vault 根路径转换为相对当前笔记的可移植 Markdown 引用。 */
|
||||
export function workspaceAssetReference(notePath: string, assetPath: string): string {
|
||||
const noteParts = relativePath(notePath).split('/').filter(Boolean)
|
||||
noteParts.pop()
|
||||
return `${'../'.repeat(noteParts.length)}${relativePath(assetPath)}`
|
||||
}
|
||||
|
||||
/** 将笔记内的相对附件引用还原为 Vault 根路径。 */
|
||||
export function resolveWorkspaceAssetPath(notePath: string, reference: string): string | null {
|
||||
if (/^(?:[a-z]+:|\/\/|#)/i.test(reference)) return null
|
||||
const parts = [...relativePath(notePath).split('/').slice(0, -1)]
|
||||
for (const part of reference.replace(/\\/g, '/').split('/')) {
|
||||
if (!part || part === '.') continue
|
||||
if (part === '..') { if (!parts.length) return null; parts.pop() }
|
||||
else parts.push(part)
|
||||
}
|
||||
const path = parts.join('/')
|
||||
return path.startsWith('attachments/') ? path : null
|
||||
}
|
||||
|
||||
export async function storeWorkspaceImage(
|
||||
file: Blob & { name?: string }, source: WorkspaceAssetSource,
|
||||
notePath: string, noteId: string | null,
|
||||
): Promise<WorkspaceAsset & { reference: string }> {
|
||||
const asset = await apiClient.postBinary<WorkspaceAsset>('/api/workspace/assets', file, { 'Content-Type': 'application/octet-stream' }, {
|
||||
filename: file.name || 'image', note_id: noteId || '', note_path: notePath, source,
|
||||
})
|
||||
return { ...asset, reference: workspaceAssetReference(notePath, asset.path) }
|
||||
}
|
||||
|
||||
export async function loadWorkspaceImage(path: string, notePath = '', noteId: string | null = null): Promise<Blob> {
|
||||
const response = await apiClient.get<Response>('/api/workspace/assets/content', { params: { path, note_path: notePath, note_id: noteId || '' } })
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
/** 解析已与工作空间路径关联的后端笔记标识。 */
|
||||
export async function getNoteId(filePath: string): Promise<string> {
|
||||
return requireNoteId(filePath)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOCK_FILES = (
|
||||
Path("backend/uv.lock"),
|
||||
Path("server sync/uv.lock"),
|
||||
Path("community-server/uv.lock"),
|
||||
)
|
||||
|
||||
REPLACEMENTS = {
|
||||
"https://pypi.org/simple": "https://mirrors.aliyun.com/pypi/simple",
|
||||
"https://files.pythonhosted.org/packages/": "https://mirrors.aliyun.com/pypi/packages/",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for path in LOCK_FILES:
|
||||
if not path.is_file():
|
||||
continue
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for source, mirror in REPLACEMENTS.items():
|
||||
content = content.replace(source, mirror)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
print(f"已切换锁文件下载源:{path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
.gitea
|
||||
.venv
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
console/node_modules
|
||||
console/dist
|
||||
tests
|
||||
tools
|
||||
backups
|
||||
data
|
||||
vault
|
||||
*.sqlite*
|
||||
*.db
|
||||
@@ -1,10 +1,12 @@
|
||||
# 所有值由部署维护者生成;数据库 URL 中密码须进行 URL 编码。
|
||||
OPENNEXUS_SYNC_TAG=0.3.1-alpha.3
|
||||
POSTGRES_PASSWORD=
|
||||
SYNC_DATABASE_URL=
|
||||
MINIO_ROOT_USER=
|
||||
MINIO_ROOT_PASSWORD=
|
||||
SYNC_ACCESS_KEY_ID=
|
||||
SYNC_SECRET_ACCESS_KEY=
|
||||
SYNC_S3_BUCKET=opennexus
|
||||
# 默认只监听本机;仅在已隔离的明文 HTTP 测试阶段显式改为 0.0.0.0。
|
||||
SYNC_BIND_ADDRESS=127.0.0.1
|
||||
SYNC_PORT=8080
|
||||
|
||||
@@ -7,6 +7,10 @@ COPY console ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM python:3.12-slim
|
||||
ARG OPENNEXUS_SYNC_VERSION=0.3.1-alpha.3
|
||||
LABEL org.opencontainers.image.title="OpenNexus Server Sync" \
|
||||
org.opencontainers.image.version="${OPENNEXUS_SYNC_VERSION}" \
|
||||
org.opencontainers.image.source="https://gitea.kronecker.cc/Kronecker/NotesAgentic"
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.24 /uv /bin/uv
|
||||
WORKDIR /service
|
||||
COPY pyproject.toml uv.lock ./
|
||||
@@ -16,4 +20,5 @@ COPY --from=console /sync_server/static ./sync_server/static
|
||||
RUN useradd --uid 10001 --create-home opennexus && mkdir /staging && chown opennexus /staging
|
||||
USER 10001
|
||||
ENV SYNC_STAGING_DIR=/staging
|
||||
EXPOSE 8080
|
||||
CMD ["/service/.venv/bin/python", "-m", "sync_server", "serve"]
|
||||
|
||||
+32
-7
@@ -1,6 +1,6 @@
|
||||
# OpenNexus Sync 服务原型
|
||||
# OpenNexus Server Sync
|
||||
|
||||
协议及限制见 [Sync v1](../docs/contracts/Sync-v1契约.md)。服务独立于 AI Core,生产入口仅支持 PostgreSQL 和 S3。当前尚未达到 M3 运维退出条件。
|
||||
当前发布版本为 **0.3.1-alpha.3**。协议及限制见 [Sync v1](../docs/contracts/Sync-v1契约.md)。服务独立于 AI Core,生产入口仅支持 PostgreSQL 和 S3 兼容对象存储。独立发布包包含服务源码、锁文件、Vue 3 + TypeScript 管理控制台静态文件、Dockerfile 与 Compose 模板,不包含任何 Vault、账户数据库、对象存储数据或部署密钥。
|
||||
|
||||
服务根路径 `/` 与 `/console/` 提供同源的 Vue 3 + TypeScript Sync Console,可查看服务健康与依赖就绪状态,并使用普通 Sync 账户管理自己的 Vault 和设备。页面只调用公开的 Sync v1 API;密码在请求发出前从输入框清除,访问和刷新令牌只保留在页面内存,刷新或关闭页面即丢弃。控制台源码位于 `console/`,生产静态文件由 Docker 多阶段构建生成。
|
||||
|
||||
@@ -22,17 +22,42 @@ uv run pytest
|
||||
|
||||
## 自托管准备
|
||||
|
||||
1. 将 `.env.example` 复制为 `.env`,生成独立数据库、MinIO 管理和同步访问凭据。数据库 URL 使用 `postgresql+psycopg://…`,其中密码须 URL 编码。
|
||||
从发布页下载 `OpenNexus-Server-Sync-0.3.1-alpha.3.zip` 并核对 `SHA256.json` 后,将压缩包解压到独立目录。升级现有实例时先备份数据库、对象存储和 `.env`,再使用新版镜像替换 Sync 服务;不要用发行包覆盖持久化卷。
|
||||
|
||||
仓库提供以下 Docker 文件:
|
||||
|
||||
- `Dockerfile`:构建 Vue 控制台和只读运行镜像。
|
||||
- `compose.yaml`:启动 PostgreSQL、MinIO、一次性初始化任务和 Sync 服务,默认只监听 `127.0.0.1:8080`。
|
||||
- `compose.test.yaml`:仅供隔离验收使用,将 Sync 暴露到 `0.0.0.0:18080` 并使用 MinIO 管理凭据。
|
||||
- `.dockerignore`:排除密钥、数据库、Vault、测试缓存和本机依赖。
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
# 编辑 .env 后启动生产形态
|
||||
docker compose up -d --build
|
||||
|
||||
# 或在隔离测试机直接开放 18080;需要 Docker Compose 2.24.4+
|
||||
docker compose -f compose.yaml -f compose.test.yaml up -d --build
|
||||
```
|
||||
|
||||
1. 将 `.env.example` 复制为 `.env`,生成独立数据库、MinIO 管理和同步访问凭据。数据库 URL 使用 `postgresql+psycopg://…`,其中密码须 URL 编码。升级现有实例时,将 `SYNC_S3_BUCKET` 保持为原实例的 Bucket 名称。
|
||||
2. 执行 `docker compose up -d`。一次性 `initialize` 服务等待依赖后幂等创建 schema 与 `opennexus` Bucket;重复运行只检查并补齐缺失资源,不覆盖已有行或对象。长期运行的 `sync` 服务继续使用只限该 Bucket 的同步账号,不使用 MinIO root 身份。
|
||||
3. 执行 `docker compose run --rm sync /service/.venv/bin/python -m sync_server create-user`,密码交互输入,不放命令参数。
|
||||
4. 使用 Caddy 示例配置 TLS。默认通过 `SYNC_BIND_ADDRESS=127.0.0.1` 与
|
||||
3. 全新数据库会生成账户 `admin` 和本次启动专用的随机密码。使用 `docker compose logs sync` 查找 `SYNC_BOOTSTRAP_CREDENTIALS`;随机密码不会写入镜像、环境变量或数据库明文。只要账户尚未固定,服务每次重启都会更换该密码并撤销旧会话。
|
||||
4. 使用随机密码首次登录控制台后,必须立即修改账户名和密码。保存成功后凭据写入数据库,此后服务重启不再更换。已有正式账户的升级实例不会额外创建默认账户。仍可使用 `create-user` 运维命令增加独立账户,密码通过终端交互输入。
|
||||
5. 使用 Caddy 示例配置 TLS。默认通过 `SYNC_BIND_ADDRESS=127.0.0.1` 与
|
||||
`SYNC_PORT=8080` 只监听本机。仅限已授权的隔离测试阶段将监听地址改为
|
||||
`0.0.0.0` 并直接开放测试端口;该模式不作为生产发布配置。
|
||||
5. 检查 `/health`、`/ready` 及经过授权的上传/读取;`/ready` 探测数据库 schema、staging 读写和对象存储测试前缀。
|
||||
6. 检查 `/health`、`/ready` 及经过授权的上传/读取;`/ready` 探测数据库 schema、staging 读写和对象存储测试前缀。
|
||||
|
||||
如需让保留旧数据的升级实例执行一次首次设置,可运行下列命令。它只新增临时管理员,不删除旧账户、Vault 或对象;命令输出的随机密码在固定前也会随服务重启而失效。
|
||||
|
||||
```powershell
|
||||
docker compose run --rm sync /service/.venv/bin/python -m sync_server bootstrap-user
|
||||
```
|
||||
|
||||
`initialize` 命令已通过真实 PostgreSQL/MinIO 的空实例与重复运行验证,并由 Compose 的一次性服务调用。MinIO 同步账号仍须由管理员创建并限制到 `opennexus` Bucket,`.env` 中的 root 与同步凭据必须不同。
|
||||
|
||||
2026-09-08 已在独立 Docker 项目完成真实 PostgreSQL/MinIO 双 worker 测试部署,修正基础镜像中的 `sync` 系统用户名冲突。测试专用 HTTP 地址、故障检查、完整验收缺口与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。S-07 已在原生 PostgreSQL 17.11/MinIO 实例完成 1 GiB/10,000 文件的删除源实例与空实例恢复;当前机器没有 Docker CLI,因此修改后的 Compose 编排仍需在发布环境复演,生产 TLS 也仍是独立发布门。
|
||||
0.3.1-alpha.3 使用真实 PostgreSQL/MinIO 环境验证初始化、重复启动、固定凭据、健康检查和已有数据升级。测试专用 HTTP 地址、故障检查、完整验收记录与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。S-07 已在原生 PostgreSQL 17.11/MinIO 实例完成 1 GiB/10,000 文件的删除源实例与空实例恢复。测试阶段可以直接开放 HTTP 端口;生产上线仍需配置 TLS、访问控制、监控与异机备份。
|
||||
|
||||
## 备份与空实例恢复
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 仅用于隔离的明文 HTTP 验收环境;生产部署不要加载此覆盖文件。
|
||||
services:
|
||||
sync:
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?required}
|
||||
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?required}
|
||||
ports: !override
|
||||
- "0.0.0.0:${SYNC_PORT:-18080}:8080"
|
||||
restart: unless-stopped
|
||||
@@ -20,12 +20,16 @@ services:
|
||||
volumes:
|
||||
- objects:/data
|
||||
initialize:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.3.1-alpha.3}
|
||||
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.3.1-alpha.3}
|
||||
command: ["/service/.venv/bin/python", "-m", "sync_server", "initialize"]
|
||||
environment:
|
||||
SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required}
|
||||
SYNC_S3_ENDPOINT: http://objects:9000
|
||||
SYNC_S3_BUCKET: opennexus
|
||||
SYNC_S3_BUCKET: ${SYNC_S3_BUCKET:-opennexus}
|
||||
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?required}
|
||||
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?required}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
@@ -36,11 +40,15 @@ services:
|
||||
condition: service_started
|
||||
restart: "no"
|
||||
sync:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.3.1-alpha.3}
|
||||
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.3.1-alpha.3}
|
||||
environment:
|
||||
SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required}
|
||||
SYNC_S3_ENDPOINT: http://objects:9000
|
||||
SYNC_S3_BUCKET: opennexus
|
||||
SYNC_S3_BUCKET: ${SYNC_S3_BUCKET:-opennexus}
|
||||
AWS_ACCESS_KEY_ID: ${SYNC_ACCESS_KEY_ID:?required}
|
||||
AWS_SECRET_ACCESS_KEY: ${SYNC_SECRET_ACCESS_KEY:?required}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
@@ -57,6 +65,12 @@ services:
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop: [ALL]
|
||||
healthcheck:
|
||||
test: ["CMD", "/service/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/ready', timeout=3).read()"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 20s
|
||||
volumes:
|
||||
postgres:
|
||||
objects:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "opennexus-sync-console",
|
||||
"private": true,
|
||||
"version": "0.3.0-alpha.1",
|
||||
"version": "0.3.1-alpha.3",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,11 @@ const username = ref('')
|
||||
const password = ref('')
|
||||
const deviceName = ref('OpenNexus Web Console')
|
||||
const sessionLabel = ref('')
|
||||
const credentialsRequired = ref(false)
|
||||
const currentPassword = ref('')
|
||||
const newUsername = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const newVaultName = ref('')
|
||||
const vaults = ref<Vault[]>([])
|
||||
const devices = ref<Device[]>([])
|
||||
@@ -72,6 +77,10 @@ function leaveConsole() {
|
||||
password.value = ''
|
||||
vaults.value = []
|
||||
devices.value = []
|
||||
credentialsRequired.value = false
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
@@ -82,17 +91,41 @@ async function signIn() {
|
||||
const device = deviceName.value.trim()
|
||||
password.value = ''
|
||||
try {
|
||||
await api.login(account, secret, device)
|
||||
credentialsRequired.value = await api.login(account, secret, device)
|
||||
sessionLabel.value = `${account} · ${device}`
|
||||
newUsername.value = account
|
||||
signedIn.value = true
|
||||
await loadAccount()
|
||||
notify('设备会话已建立')
|
||||
if (!credentialsRequired.value) await loadAccount()
|
||||
notify(credentialsRequired.value ? '请立即固定账户与密码' : '设备会话已建立')
|
||||
} catch (error) {
|
||||
leaveConsole()
|
||||
notify(error instanceof Error ? error.message : 'LOGIN_FAILED', true)
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function fixCredentials() {
|
||||
if (busy.value) return
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
notify('两次输入的新密码不一致', true)
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
const oldSecret = currentPassword.value
|
||||
const nextSecret = newPassword.value
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
try {
|
||||
const result = await api.changeCredentials(oldSecret, newUsername.value.trim(), nextSecret)
|
||||
credentialsRequired.value = false
|
||||
sessionLabel.value = `${result.username} · ${deviceName.value.trim()}`
|
||||
await loadAccount()
|
||||
notify('账户与密码已固定;以后重启不会再随机更换')
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : 'CREDENTIAL_CHANGE_FAILED', true)
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function createVault() {
|
||||
const name = newVaultName.value.trim()
|
||||
if (!name || busy.value) return
|
||||
@@ -198,6 +231,22 @@ onBeforeUnmount(() => {
|
||||
<button class="secondary-button" type="button" :disabled="busy" @click="logout">退出登录</button>
|
||||
</div>
|
||||
|
||||
<section v-if="credentialsRequired" class="login-card credential-card" aria-labelledby="credential-title">
|
||||
<div class="card-heading">
|
||||
<span class="lock-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></svg></span>
|
||||
<div><p>首次登录</p><h2 id="credential-title">固定账户凭据</h2></div>
|
||||
</div>
|
||||
<p class="surface-intro">当前密码只对本次服务启动有效。修改账户和密码后,凭据将写入数据库并在后续重启中保持不变。</p>
|
||||
<form @submit.prevent="fixCredentials">
|
||||
<label>当前随机密码<input v-model="currentPassword" type="password" autocomplete="current-password" minlength="12" maxlength="256" required></label>
|
||||
<label>新账户<input v-model="newUsername" autocomplete="username" maxlength="80" required></label>
|
||||
<label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label>
|
||||
<label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label>
|
||||
<button class="primary-button" type="submit" :disabled="busy || !currentPassword || !newPassword || !confirmPassword">{{ busy ? '正在保存…' : '保存并固定凭据' }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<div class="metric-grid">
|
||||
<article><span>远端 Vault</span><strong>{{ vaults.length }}</strong><small>当前账户可访问</small></article>
|
||||
<article><span>已使用空间</span><strong>{{ formatBytes(used) }}</strong><small>总配额 {{ formatBytes(quota) }}</small></article>
|
||||
@@ -242,6 +291,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Session {
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
device_id: string
|
||||
must_change_credentials: boolean
|
||||
}
|
||||
|
||||
export interface Vault {
|
||||
@@ -39,6 +40,7 @@ export class SyncApi {
|
||||
private access = ''
|
||||
private refresh = ''
|
||||
deviceId = ''
|
||||
mustChangeCredentials = false
|
||||
|
||||
get signedIn() { return Boolean(this.access) }
|
||||
|
||||
@@ -80,6 +82,7 @@ export class SyncApi {
|
||||
this.access = session.access_token
|
||||
this.refresh = session.refresh_token
|
||||
this.deviceId = session.device_id
|
||||
this.mustChangeCredentials = Boolean(session.must_change_credentials)
|
||||
}
|
||||
|
||||
async status(): Promise<ServiceStatus> {
|
||||
@@ -103,7 +106,7 @@ export class SyncApi {
|
||||
}
|
||||
}
|
||||
|
||||
async login(username: string, password: string, deviceName: string): Promise<void> {
|
||||
async login(username: string, password: string, deviceName: string): Promise<boolean> {
|
||||
const response = await this.raw('/sync/v1/auth/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password, device_name: deviceName }),
|
||||
@@ -115,6 +118,17 @@ export class SyncApi {
|
||||
const session = await safeJson<Session>(response)
|
||||
if (!session) throw new Error('INVALID_RESPONSE')
|
||||
this.accept(session)
|
||||
return this.mustChangeCredentials
|
||||
}
|
||||
|
||||
async changeCredentials(currentPassword: string, username: string, password: string) {
|
||||
const result = await this.request<{ username: string; credentials_fixed: boolean }>(
|
||||
'/sync/v1/account/credentials', {
|
||||
method: 'PUT', body: JSON.stringify({ current_password: currentPassword, username, password }),
|
||||
}, false,
|
||||
)
|
||||
this.mustChangeCredentials = false
|
||||
return result
|
||||
}
|
||||
|
||||
vaults() { return this.request<{ items: Vault[] }>('/sync/v1/vaults') }
|
||||
@@ -136,5 +150,6 @@ export class SyncApi {
|
||||
this.access = ''
|
||||
this.refresh = ''
|
||||
this.deviceId = ''
|
||||
this.mustChangeCredentials = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ main { width: min(1180px, calc(100% - 48px)); margin: 0 auto; position: relative
|
||||
.protocol-grid strong { font-size: 18px; font-weight: 600; }
|
||||
.protocol-grid span { color: var(--muted); font-size: 11px; }
|
||||
.login-card { padding: 31px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); position: relative; overflow: hidden; }
|
||||
.credential-card { width: min(100%, 560px); margin: 32px auto 0; }
|
||||
.card-glow { position: absolute; width: 210px; height: 210px; right: -100px; top: -120px; border-radius: 50%; background: var(--accent); filter: blur(60px); opacity: .08; }
|
||||
.card-heading { display: flex; gap: 14px; align-items: center; margin-bottom: 28px; position: relative; }
|
||||
.card-heading p, .surface-heading p { margin: 0 0 3px; color: var(--accent); font-size: 10px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "notesagent-sync"
|
||||
version = "0.3.0a1"
|
||||
version = "0.3.1a3"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.116,<1", "uvicorn>=0.35,<1", "sqlalchemy>=2.0,<2.1",
|
||||
|
||||
@@ -24,7 +24,7 @@ def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["serve", "initialize", "migrate", "create-user", "cleanup-uploads", "backup", "restore"],
|
||||
choices=["serve", "initialize", "migrate", "create-user", "bootstrap-user", "cleanup-uploads", "backup", "restore"],
|
||||
)
|
||||
parser.add_argument("--workers", type=int, choices=[1, 2], default=2)
|
||||
parser.add_argument("--username")
|
||||
@@ -80,8 +80,25 @@ def main():
|
||||
elif args.command == "create-user":
|
||||
db.migrate()
|
||||
db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): "))
|
||||
elif args.command == "bootstrap-user":
|
||||
db.migrate()
|
||||
bootstrap = db.prepare_bootstrap_user(force=True)
|
||||
print(json.dumps({
|
||||
"event": "SYNC_BOOTSTRAP_CREDENTIALS",
|
||||
"username": bootstrap["username"],
|
||||
"password": bootstrap["password"],
|
||||
"must_change_credentials": True,
|
||||
}), flush=True)
|
||||
elif args.command == "serve":
|
||||
db.migrate()
|
||||
bootstrap = db.prepare_bootstrap_user()
|
||||
if bootstrap:
|
||||
print(json.dumps({
|
||||
"event": "SYNC_BOOTSTRAP_CREDENTIALS",
|
||||
"username": bootstrap["username"],
|
||||
"password": bootstrap["password"],
|
||||
"must_change_credentials": True,
|
||||
}), flush=True)
|
||||
import uvicorn
|
||||
host = os.environ.get("SYNC_HOST", "0.0.0.0")
|
||||
if host not in {"0.0.0.0", "127.0.0.1", "::1"}:
|
||||
|
||||
@@ -19,7 +19,7 @@ from starlette.concurrency import run_in_threadpool
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from .database import Database, password_hash, row, rows, run
|
||||
from .models import Commit, Login, Refresh, Upload, VaultCreate
|
||||
from .models import Commit, CredentialChange, Login, Refresh, Upload, VaultCreate
|
||||
from .readiness import Readiness
|
||||
|
||||
|
||||
@@ -105,11 +105,14 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
# Pydantic 的原始错误可能带请求正文,禁止回显密码或笔记。
|
||||
return JSONResponse({"error": {"code": "INVALID_REQUEST", "details": {}}}, status_code=422)
|
||||
|
||||
def identity(conn, authorization):
|
||||
def identity(conn, authorization, *, allow_bootstrap=False):
|
||||
token = authorization.removeprefix("Bearer ") if authorization.startswith("Bearer ") else ""
|
||||
session = row(conn, "SELECT s.*, d.user_id, d.revoked FROM sessions s JOIN devices d ON d.id=s.device_id WHERE s.token=:token", token=digest(token))
|
||||
if not session or session["revoked"] or session["expires"] <= clock():
|
||||
raise SyncError(401, "SESSION_EXPIRED")
|
||||
if not allow_bootstrap and row(conn, "SELECT user_id FROM bootstrap_state WHERE user_id=:user",
|
||||
user=session["user_id"]):
|
||||
raise SyncError(403, "CREDENTIAL_CHANGE_REQUIRED")
|
||||
return session
|
||||
|
||||
def vault(conn, vault_id, authorization, *, lock=False):
|
||||
@@ -127,7 +130,11 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
run(conn, "INSERT INTO sessions VALUES (:token,:refresh,:device,:expires,:refresh_expires)",
|
||||
token=digest(access), refresh=digest(refresh), device=device_id,
|
||||
expires=int(clock()) + 900, refresh_expires=int(clock()) + 30 * 86400)
|
||||
return {"access_token": access, "refresh_token": refresh, "expires_in": 900, "device_id": device_id}
|
||||
device = row(conn, "SELECT user_id FROM devices WHERE id=:id", id=device_id)
|
||||
must_change = bool(row(conn, "SELECT user_id FROM bootstrap_state WHERE user_id=:user",
|
||||
user=device["user_id"]))
|
||||
return {"access_token": access, "refresh_token": refresh, "expires_in": 900,
|
||||
"device_id": device_id, "must_change_credentials": must_change}
|
||||
|
||||
@app.get("/health")
|
||||
def health(response: Response):
|
||||
@@ -210,9 +217,31 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
@app.delete("/sync/v1/auth/sessions", status_code=204)
|
||||
def logout(authorization: str = Header(default="")):
|
||||
with db.transaction() as conn:
|
||||
session = identity(conn, authorization)
|
||||
session = identity(conn, authorization, allow_bootstrap=True)
|
||||
run(conn, "DELETE FROM sessions WHERE token=:token", token=session["token"])
|
||||
|
||||
@app.put("/sync/v1/account/credentials")
|
||||
def change_credentials(body: CredentialChange, authorization: str = Header(default="")):
|
||||
with db.transaction() as conn:
|
||||
session = identity(conn, authorization, allow_bootstrap=True)
|
||||
user = row(conn, "SELECT * FROM users WHERE id=:id", id=session["user_id"])
|
||||
expected = user["password"]
|
||||
if not secrets.compare_digest(
|
||||
password_hash(body.current_password, expected.split(":")[0]), expected):
|
||||
raise SyncError(401, "CURRENT_PASSWORD_INVALID")
|
||||
conflict = row(conn, "SELECT id FROM users WHERE username=:name AND id<>:id",
|
||||
name=body.username, id=user["id"])
|
||||
if conflict:
|
||||
raise SyncError(409, "USERNAME_TAKEN")
|
||||
run(conn, "UPDATE users SET username=:name,password=:password WHERE id=:id",
|
||||
name=body.username, password=password_hash(body.password), id=user["id"])
|
||||
run(conn, "DELETE FROM bootstrap_state WHERE user_id=:user", user=user["id"])
|
||||
run(conn, "DELETE FROM sessions WHERE device_id IN (SELECT id FROM devices WHERE user_id=:user) AND token<>:token",
|
||||
user=user["id"], token=session["token"])
|
||||
run(conn, "UPDATE devices SET revoked=1 WHERE user_id=:user AND id<>:device",
|
||||
user=user["id"], device=session["device_id"])
|
||||
return {"username": body.username, "credentials_fixed": True}
|
||||
|
||||
@app.get("/sync/v1/devices")
|
||||
def devices(authorization: str = Header(default="")):
|
||||
with db.transaction() as conn:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
SCHEMA = [
|
||||
@@ -17,6 +18,7 @@ SCHEMA = [
|
||||
"CREATE TABLE IF NOT EXISTS files (vault_id TEXT NOT NULL, file_id TEXT NOT NULL, sequence BIGINT NOT NULL, path_key TEXT NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(vault_id, file_id))",
|
||||
"CREATE TABLE IF NOT EXISTS login_limits (key TEXT PRIMARY KEY, started BIGINT NOT NULL, attempts INTEGER NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS upload_receipts (id TEXT PRIMARY KEY, vault_id TEXT NOT NULL, device_id TEXT NOT NULL, hash TEXT NOT NULL, completed BIGINT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS bootstrap_state (user_id TEXT PRIMARY KEY, created BIGINT NOT NULL)",
|
||||
]
|
||||
|
||||
|
||||
@@ -66,6 +68,36 @@ class Database:
|
||||
conn.execute(text("INSERT INTO users VALUES (:id,:name,:password)"),
|
||||
{"id": secrets.token_hex(16), "name": username, "password": password_hash(password)})
|
||||
|
||||
def prepare_bootstrap_user(self, *, force=False):
|
||||
"""在账户尚未固定时生成本次服务启动专用的临时密码。"""
|
||||
password = secrets.token_urlsafe(24)
|
||||
with self.transaction() as conn:
|
||||
state = row(conn, "SELECT user_id FROM bootstrap_state")
|
||||
user = row(conn, "SELECT * FROM users WHERE id=:id", id=state["user_id"]) if state else None
|
||||
if state and not user:
|
||||
run(conn, "DELETE FROM bootstrap_state")
|
||||
state = None
|
||||
if not state:
|
||||
if row(conn, "SELECT id FROM users LIMIT 1") and not force:
|
||||
return None
|
||||
user_id = secrets.token_hex(16)
|
||||
username = "admin"
|
||||
if row(conn, "SELECT id FROM users WHERE username=:name", name=username):
|
||||
username = "bootstrap-admin-" + secrets.token_hex(3)
|
||||
run(conn, "INSERT INTO users VALUES (:id,:name,:password)",
|
||||
id=user_id, name=username, password=password_hash(password))
|
||||
run(conn, "INSERT INTO bootstrap_state VALUES (:user,:created)",
|
||||
user=user_id, created=int(time.time()))
|
||||
else:
|
||||
user_id = state["user_id"]
|
||||
run(conn, "UPDATE users SET password=:password WHERE id=:id",
|
||||
password=password_hash(password), id=user_id)
|
||||
run(conn, "DELETE FROM sessions WHERE device_id IN (SELECT id FROM devices WHERE user_id=:user)",
|
||||
user=user_id)
|
||||
run(conn, "UPDATE devices SET revoked=1 WHERE user_id=:user", user=user_id)
|
||||
account = row(conn, "SELECT username FROM users WHERE id=:id", id=user_id)
|
||||
return {"username": account["username"], "password": password}
|
||||
|
||||
|
||||
def row(conn, sql, **params):
|
||||
return conn.execute(text(sql), params).mappings().first()
|
||||
|
||||
@@ -20,6 +20,19 @@ class Refresh(DTO):
|
||||
refresh_token: str = Field(min_length=32, max_length=256)
|
||||
|
||||
|
||||
class CredentialChange(DTO):
|
||||
current_password: str = Field(min_length=12, max_length=256)
|
||||
username: str = Field(min_length=1, max_length=80)
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def username_valid(cls, value):
|
||||
if value != value.strip():
|
||||
raise ValueError("账户名首尾不能包含空白")
|
||||
return value
|
||||
|
||||
|
||||
class VaultCreate(DTO):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ TABLES: dict[str, tuple[str, ...]] = {
|
||||
"files": ("vault_id", "file_id", "sequence", "path_key", "deleted"),
|
||||
"login_limits": ("key", "started", "attempts"),
|
||||
"upload_receipts": ("id", "vault_id", "device_id", "hash", "completed"),
|
||||
"bootstrap_state": ("user_id", "created"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#07120f">
|
||||
<title>OpenNexus Sync Console</title>
|
||||
<script type="module" crossorigin src="/console/assets/index-CsQwWg1J.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/console/assets/index-B8qnzSCe.css">
|
||||
<script type="module" crossorigin src="/console/assets/index-C4AkVW4e.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/console/assets/index-xFodnbVC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -25,6 +25,18 @@ def env(tmp_path):
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
def test_upgrade_can_add_bootstrap_without_removing_existing_accounts(tmp_path):
|
||||
db = Database("sqlite:///" + str(tmp_path / "upgrade.db"))
|
||||
db.migrate()
|
||||
db.add_user("existing", "existing-account-password")
|
||||
assert db.prepare_bootstrap_user() is None
|
||||
bootstrap = db.prepare_bootstrap_user(force=True)
|
||||
assert bootstrap["username"] == "admin"
|
||||
with db.transaction() as conn:
|
||||
assert conn.exec_driver_sql("SELECT COUNT(*) FROM users").scalar() == 2
|
||||
db.engine.dispose()
|
||||
|
||||
|
||||
def session(client, user="alice"):
|
||||
response = client.post("/sync/v1/auth/sessions", json={"username": user, "password": "controlled-fixture-password", "device_name": "测试设备"})
|
||||
assert response.status_code == 200, response.text
|
||||
@@ -153,3 +165,45 @@ def test_login_limits_and_protocol(env):
|
||||
for _ in range(10):
|
||||
assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 401
|
||||
assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 429
|
||||
|
||||
|
||||
def test_bootstrap_password_rotates_until_account_is_fixed(tmp_path):
|
||||
db = Database("sqlite:///" + str(tmp_path / "bootstrap.db"))
|
||||
db.migrate()
|
||||
first = db.prepare_bootstrap_user()
|
||||
second = db.prepare_bootstrap_user()
|
||||
assert first["username"] == second["username"] == "admin"
|
||||
assert first["password"] != second["password"]
|
||||
|
||||
app = create_app(db, DiskObjects(tmp_path / "objects"), tmp_path / "staging")
|
||||
with TestClient(app) as client:
|
||||
old = client.post("/sync/v1/auth/sessions", json={
|
||||
"username": "admin", "password": first["password"], "device_name": "旧启动",
|
||||
})
|
||||
assert old.status_code == 401
|
||||
login = client.post("/sync/v1/auth/sessions", json={
|
||||
"username": "admin", "password": second["password"], "device_name": "首次登录",
|
||||
})
|
||||
assert login.status_code == 200
|
||||
assert login.json()["must_change_credentials"] is True
|
||||
headers = {"Authorization": "Bearer " + login.json()["access_token"]}
|
||||
blocked = client.get("/sync/v1/vaults", headers=headers)
|
||||
assert blocked.status_code == 403
|
||||
assert blocked.json()["error"]["code"] == "CREDENTIAL_CHANGE_REQUIRED"
|
||||
|
||||
changed = client.put("/sync/v1/account/credentials", headers=headers, json={
|
||||
"current_password": second["password"],
|
||||
"username": "owner",
|
||||
"password": "fixed-production-password",
|
||||
})
|
||||
assert changed.json() == {"username": "owner", "credentials_fixed": True}
|
||||
assert client.post("/sync/v1/vaults", headers=headers, json={"name": "固定账户"}).status_code == 200
|
||||
|
||||
assert db.prepare_bootstrap_user() is None
|
||||
with TestClient(app) as client:
|
||||
login = client.post("/sync/v1/auth/sessions", json={
|
||||
"username": "owner", "password": "fixed-production-password", "device_name": "重启后",
|
||||
})
|
||||
assert login.status_code == 200
|
||||
assert login.json()["must_change_credentials"] is False
|
||||
db.engine.dispose()
|
||||
|
||||
Generated
+1
-1
@@ -225,7 +225,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "notesagent-sync"
|
||||
version = "0.3.0a1"
|
||||
version = "0.3.1a3"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
Reference in New Issue
Block a user