diff --git a/2.Design/API.md b/2.Design/API.md index 78e520b..6ca6d2f 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -396,31 +396,40 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单; 正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。 理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。 -图片不是 base64 塞进正文,而是走附件接口拿一个稳定地址: +图片不是 base64 塞进正文,而是走附件接口拿地址。**库里存的是不带凭据的相对路径**: ```markdown -![票据截图.png](/api/v1/workspaces/{ws_id}/attachments/{attachment_id}/raw?token=...) +![票据截图.png](/api/v1/workspaces/{ws_id}/attachments/{attachment_id}/raw) ``` | 方法 | 路径 | 说明 | |---|---|---| | POST | `/workspaces/{ws_id}/items/{item_id}/attachments` | multipart,字段名 `file`;返回 201 + 附件对象 | -| GET | `/workspaces/{ws_id}/items/{item_id}/attachments` | `{items: [...]}` | +| GET | `/workspaces/{ws_id}/items/{item_id}/attachments` | `{items: [...]}`,每条含 `raw_url` | | GET | `/workspaces/{ws_id}/attachments/{id}/download` | 下载(`Content-Disposition: attachment`) | -| GET | `/workspaces/{ws_id}/attachments/{id}/raw?token=` | 直链,给 `` 用 | +| GET | `/workspaces/{ws_id}/attachments/{id}/raw?exp=&sig=` | 直链,给 `` 用(签名见 6.2) | | DELETE | `/workspaces/{ws_id}/attachments/{id}?confirm=true` | 上传者或管理员可删;不传 confirm 报 `8004` | -附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / can_delete`。 +附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。 +`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。 事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。 -### 6.2 为什么图片直链要带 token +### 6.2 图片直链:短期签名,而不是把令牌写进正文 -`` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文会膨胀几十倍,且破坏检索), -或者给一个生命周期短的签名地址。 +`` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索), +或者给一个短期签名地址。取后者: -当前实现取的是"query 里带 access token",**并且只允许本工作区成员访问**(非成员一律 `8001`, -不区分"不存在"与"没权限",避免探测他人附件)。这是本地/内网阶段的折中,上生产前应换成 -**带过期时间的一次性签名 URL**,见 `ROADMAP` 的 M7 安全清单。 +- `raw_url` 形如 `…/raw?exp=1730000000&sig=`,签名载荷是 `workspace_id:attachment_id:exp`, + 密钥从 `JWT_SECRET` 域分离派生(`attachment-raw-v1`),不与登录令牌共用。 +- 校验只看签名与过期时间,**不需要用户已登录**;签名里绑了 `workspace_id`,A 工作区签的链接 + 打到 B 工作区会被拒(`8003`)。 +- 过期不宽限,刷新页面即可拿到新链接。 +- 兼容旧路径:不带 `sig` 时仍可用 `?token=` 走成员校验,供老客户端过渡。 + +为什么非要这样:令牌写进正文会落库,几天后过期就是满篇红叉;用户换设备后旧令牌失效,图片全挂。 +把凭据放在"每次渲染时现签"的 URL 上,正文才能长期存活。 + +### 6.3 附件错误码 ### 6.3 附件错误码 diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 45b10f8..4293ae9 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -360,7 +360,8 @@ - [x] **编辑器不自研**(最初那版 textarea + 正则工具栏已删除):改用 [Vditor](https://github.com/Vanessa219/vditor) 4.0.1(MIT,中文优先,`ir` 模式所见即所得),落盘仍是 Markdown - [x] 选型依据:Vditor 11.4k★ / 依赖仅 `diff-match-patch` / 中文界面与文档齐全;TipTap 是 headless 且存 JSON 要另做序列化;`@toast-ui/react-editor` 停更在 2023 且 peer 依赖锁 React 17 - [x] 运行时资源自托管(`web/scripts/vendor-vditor.mjs` → `public/vditor`,由 `predev`/`prebuild` 自动生成),**不走 unpkg CDN**——内网/离线环境下编辑器会一直卡在加载 -- [ ] 表格与 HTML 片段:暂不支持(Vditor 支持表格,只是我们的只读渲染器还没开) +- [x] 表格:Vditor 里能插能编;只读渲染(评论、动态、预览)走 `markdown-it` 的 GFM 表,`display:block + overflow-x:auto` 让宽表在抽屉里横向滚动、不撑破布局(e2e 断言表格宽度 ≤ 900px,实测 561px) +- [ ] HTML 片段:仍按文本显示(不开放 raw HTML 是有意的,避免把消毒责任交给调用方) ### 5.2 附件 @@ -368,8 +369,8 @@ - [x] 本地磁盘存储(`backend/app/services/storage.py`),key 由内容 sha256 决定;换对象存储只改这一个文件 - [x] 上限 `MAX_UPLOAD_MB=20`,声明大小与流式读取双重拦截,超限 `8002` - [x] 列表带 `attachment_count`,详情带 `attachments`,避免一屏 N 次请求 -- [x] 下载走带 `Authorization` 的 fetch 转 blob;`` 直链走 `raw?token=`,只允许本工作区成员 -- [ ] 图片直链改一次性签名 URL(当前 token 放 query,上生产前必须换) +- [x] 下载走带 `Authorization` 的 fetch 转 blob;`` 直链走**短期签名** `raw?exp=&sig=`(HMAC 密钥由 `JWT_SECRET` 域分离派生,默认 30 分钟,跨工作区拒绝) +- [x] 图片直链改成签名 URL 后:正文里只存不带凭据的相对路径 `/raw`,凭据每次渲染现签,不再把会过期的令牌写进库 - [ ] 缩略图、图片压缩、病毒扫描:未做 ### 5.3 编辑器 @@ -377,10 +378,12 @@ - [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `![](attachmentUrl(...))`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑 - [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`` 带不了 Authorization 头,所以 `raw?token=` 是必要折中 - [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`) -- [x] 安卓:本轮未做富文本编辑,只做只读渲染 + 附件下载(M5 再议) +- [x] 安卓:`RichTextEditorScreen`(Kotlin 手写 Markdown 工具栏:B/I/标题/列表/引用/代码/链接/图片,选区包裹与行首前缀;预览用 **Markwon** 渲染,图片用 **Coil** 加载签名直链),从任务详情与知识点打开,保存按 `body_md` 回写 +- [ ] 安卓编辑器保存后回到任务列表而不是原详情抽屉(体验缺口:用户要重新点开那条任务) ### 5.4 验证 -- [x] 后端 `pytest` → **70 passed**(新增附件 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 直链鉴权) -- [x] `web/e2e/richtext.mjs` → **13 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含 token、贴图后 `/raw` 返回 200、附件重开仍在、控制台无报错) +- [x] 后端 `pytest` → **74 passed**(附件 8 个用例:上传下载、超限、跨租户、删除确认与权限、raw 签名校验、签名篡改与过期、跨工作区签名) +- [x] `web/e2e/richtext.mjs` → **15 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含凭据、贴图后 `/raw` 带签名且返回 200、附件重开仍在、表格渲染且不撑破抽屉、控制台无报错) - [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7) -- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 384.73 kB │ gzip 135.82 kB(按需加载,只进详情抽屉) +- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 385.24 kB │ gzip 136.05 kB(按需加载,只进详情抽屉) +- [x] 安卓 `assembleDebug` 成功(APK 10.1 MB),模拟器上走通「详情 → 编辑正文 → 预览 → 保存」,截图 `2.Design/screens/android-editor{,-preview}.png` diff --git a/README.md b/README.md index ebb8982..840c656 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ | 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 | | 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 | | 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 | -| 富文本:备注 / 工单说明 / 知识正文支持图文混排(Vditor 编辑器)与附件 | ✅ 后端 + Web 已通(附件接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节,e2e 13 项全绿);安卓暂只读 | -| 后端测试 | ✅ 70 passed,ruff 干净 | +| 富文本:备注 / 工单说明 / 知识正文支持图文混排与附件 | ✅ Web 用 Vditor 编辑、安卓用 Markwon 预览编辑(e2e 15 项全绿);接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节 | +| 后端测试 | ✅ 74 passed,ruff 干净 | | 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 | | LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 | diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0d53af1..a2228d9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -73,5 +73,8 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("com.squareup.okhttp3:okhttp:4.12.0") + // Markdown 预览用成熟库渲染,不自己写解析器 + implementation("io.noties.markwon:core:4.6.2") + implementation("io.coil-kt:coil-compose:2.7.0") debugImplementation("androidx.compose.ui:ui-tooling") } diff --git a/android/app/src/main/java/com/dadao/task/MainActivity.kt b/android/app/src/main/java/com/dadao/task/MainActivity.kt index a38659d..9a10156 100644 --- a/android/app/src/main/java/com/dadao/task/MainActivity.kt +++ b/android/app/src/main/java/com/dadao/task/MainActivity.kt @@ -19,12 +19,15 @@ import androidx.compose.ui.platform.LocalContext import com.dadao.task.data.Api import com.dadao.task.data.TokenStore import com.dadao.task.data.Workspace +import androidx.compose.foundation.layout.Box import com.dadao.task.ui.BoardScreen import com.dadao.task.ui.DadaoTheme +import com.dadao.task.ui.EditRequest import com.dadao.task.ui.HomeScreen import com.dadao.task.ui.KnowledgeScreen import com.dadao.task.ui.LoginScreen import com.dadao.task.ui.MembersScreen +import com.dadao.task.ui.RichTextEditorScreen import com.dadao.task.ui.TaskListScreen import com.dadao.task.ui.TicketsScreen import com.dadao.task.ui.WorkspaceScaffold @@ -68,6 +71,8 @@ private fun AppRoot(joinUri: String?) { var current by remember { mutableStateOf(null) } var tab by remember { mutableStateOf(WorkspaceTab.Tasks) } var unread by remember { mutableStateOf(0) } + // 打开富文本编辑器时非空;关掉就恢复原来的界面 + var editing by remember { mutableStateOf(null) } // 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。 LaunchedEffect(screen) { @@ -122,18 +127,62 @@ private fun AppRoot(joinUri: String?) { if (ws == null) { screen = Screen.Home } else { - WorkspaceScaffold( - workspace = ws, - tab = tab, - unread = unread, - onTab = { tab = it }, - onBackHome = { screen = Screen.Home }, - ) { - when (tab) { - WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws) - WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws) - WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws) - WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws) + Box { + WorkspaceScaffold( + workspace = ws, + tab = tab, + unread = unread, + onTab = { tab = it }, + onBackHome = { screen = Screen.Home }, + ) { + when (tab) { + WorkspaceTab.Tasks -> TaskListScreen( + api = api, + workspace = ws, + onEdit = { editing = it }, + ) + + WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws) + WorkspaceTab.Knowledge -> KnowledgeScreen( + api = api, + workspace = ws, + onEdit = { editing = it }, + ) + + WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws) + } + } + + val request = editing + if (request != null) { + RichTextEditorScreen( + api = api, + workspaceId = ws.id, + itemId = request.itemId, + initialTitle = request.title, + initialBody = request.body, + titleEnabled = request.titleEnabled, + save = { title, body -> + if (request.knowledge) { + api.updateKnowledge( + workspaceId = ws.id, + itemId = request.itemId, + version = request.version, + title = title, + body = body, + ) + } else { + api.updateItem( + workspaceId = ws.id, + itemId = request.itemId, + version = request.version, + title = title, + body = body, + ) + } + }, + onClose = { editing = null }, + ) } } } diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index b0c53d7..d303ea9 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -10,6 +10,8 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -136,8 +138,29 @@ data class ItemDetail( val tags: List = emptyList(), val version: Int = 1, val activities: List = emptyList(), + val attachments: List = emptyList(), ) +/** + * 附件。[rawUrl] 是后端签发的短时直链(带 exp/sig), + * 正文里的图片就用它,**不要**把长期凭据写进正文。 + */ +@Serializable +data class Attachment( + val id: String, + @SerialName("item_id") val itemId: String = "", + val filename: String = "", + @SerialName("content_type") val contentType: String = "application/octet-stream", + val size: Long = 0, + @SerialName("uploader_id") val uploaderId: String = "", + @SerialName("created_at") val createdAt: String = "", + @SerialName("raw_url") val rawUrl: String? = null, + @SerialName("can_delete") val canDelete: Boolean = false, +) + +@Serializable +data class AttachmentPage(val items: List = emptyList()) + @Serializable data class Invitation( val code: String, @@ -223,6 +246,7 @@ data class KnowledgeDoc( val tags: List = emptyList(), val knowledge: KnowledgeInfo = KnowledgeInfo(), val source: SourceRef? = null, + val attachments: List = emptyList(), ) @Serializable @@ -287,6 +311,13 @@ private data class KnowledgeCreateRequest( @Serializable private data class KnowledgeActionRequest(val action: String) +@Serializable +private data class ItemUpdateRequest( + val version: Int, + val title: String? = null, + @SerialName("body_md") val bodyMd: String? = null, +) + @Serializable private data class KnowledgeUpdateRequest( val version: Int, @@ -451,6 +482,67 @@ class Api(private val tokens: TokenStore) { auth = true, ) + // ---- 附件与富文本 ---- + + suspend fun attachments(workspaceId: String, itemId: String): List = + call( + path = "/workspaces/$workspaceId/items/$itemId/attachments", + auth = true, + ).items + + /** + * 上传附件。返回的 [Attachment.rawUrl] 是短时签名直链, + * 直接写进 Markdown 的 `![](...)` 即可,不需要把登录令牌塞进正文。 + */ + suspend fun uploadAttachment( + workspaceId: String, + itemId: String, + filename: String, + mimeType: String, + bytes: ByteArray, + ): Attachment = withContext(Dispatchers.IO) { + val form = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", + filename, + bytes.toRequestBody(mimeType.toMediaTypeOrNull()), + ) + .build() + + val builder = Request.Builder() + .url("$base/workspaces/$workspaceId/items/$itemId/attachments") + .post(form) + .header("Accept", "application/json") + tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") } + + try { + http.newCall(builder.build()).execute().use { response -> + val text = response.body?.string().orEmpty() + val envelope = runCatching { json.decodeFromString>(text) } + .getOrElse { throw ApiException(1500, "上传失败(HTTP ${response.code})") } + if (envelope.code != 0) throw ApiException(envelope.code, envelope.message, envelope.requestId) + envelope.data ?: throw ApiException(1500, "上传响应缺少 data") + } + } catch (e: IOException) { + throw unreachable(e) + } + } + + /** 保存正文/标题。PATCH 必须带 version,冲突由服务端返回 4009。 */ + suspend fun updateItem( + workspaceId: String, + itemId: String, + version: Int, + title: String? = null, + body: String? = null, + ): ItemDetail = call( + path = "/workspaces/$workspaceId/items/$itemId", + body = json.encodeToString(ItemUpdateRequest(version, title, body)), + auth = true, + method = "PATCH", + ) + // ---- M2 工单 ---- /** role: assigned(派给我的)/ requested(我发起的)/ all。 */ diff --git a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt index c5f8913..0be5215 100644 --- a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt @@ -55,7 +55,7 @@ private fun docTypeLabel(type: String): String = when (type) { } @Composable -fun KnowledgeScreen(api: Api, workspace: Workspace) { +fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) { val scope = rememberCoroutineScope() var tab by remember { mutableStateOf("draft") } var docs by remember { mutableStateOf>(emptyList()) } @@ -168,6 +168,7 @@ fun KnowledgeScreen(api: Api, workspace: Workspace) { workspace = workspace, doc = target, onClose = { open = null }, + onEdit = onEdit, onChanged = { scope.launch { reload() } }, @@ -249,6 +250,7 @@ private fun KnowledgeDetailDialog( onClose: () -> Unit, onChanged: () -> Unit, onFail: (Throwable) -> Unit, + onEdit: (EditRequest) -> Unit, ) { val scope = rememberCoroutineScope() var action by remember { mutableStateOf(doc.status) } @@ -288,6 +290,21 @@ private fun KnowledgeDetailDialog( }, confirmButton = { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TextButton( + onClick = { + onEdit( + EditRequest( + itemId = doc.id, + title = doc.title, + body = doc.bodyMd, + version = doc.version, + titleEnabled = action == "draft", + knowledge = true, + ), + ) + onClose() + }, + ) { Text("编辑正文") } actions.forEach { (key, label) -> TextButton(onClick = { scope.launch { diff --git a/android/app/src/main/java/com/dadao/task/ui/RichTextEditorScreen.kt b/android/app/src/main/java/com/dadao/task/ui/RichTextEditorScreen.kt new file mode 100644 index 0000000..e0b8d5b --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/RichTextEditorScreen.kt @@ -0,0 +1,428 @@ +package com.dadao.task.ui + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import coil.compose.AsyncImage +import com.dadao.task.data.Api +import com.dadao.task.data.ApiException +import com.dadao.task.data.Attachment +import io.noties.markwon.Markwon +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** 打开编辑器需要的最小上下文:改的是哪条、改前是什么。 */ +data class EditRequest( + val itemId: String, + val title: String, + val body: String, + val version: Int, + val titleEnabled: Boolean = true, + val knowledge: Boolean = false, +) + +/** + * 富文本编辑器(Markdown 存储)。 + * + * 为什么存 Markdown 而不是 HTML:任务备注、工单说明、知识正文都要能被检索、 + * 被 LLM 读、被导出;HTML 里混着行内样式,清洗成本远高于收益。 + * 因此编辑区写 Markdown,预览用 Markwon 渲染,图片传附件后用签名直链插入。 + */ +@Composable +fun RichTextEditorScreen( + api: Api, + workspaceId: String, + itemId: String, + initialTitle: String, + initialBody: String, + /** 保存动作由调用方决定:事项走 PATCH /items,知识走 PATCH /knowledge。 */ + save: suspend (title: String?, body: String) -> Unit, + titleEnabled: Boolean = true, + onClose: (saved: Boolean) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var title by remember { mutableStateOf(initialTitle) } + var field by remember { mutableStateOf(TextFieldValue(initialBody)) } + var preview by remember { mutableStateOf(false) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var images by remember { mutableStateOf(emptyList()) } + + // 传过的图片:进来先拉一份,预览区与「图片」栏都用它 + LaunchedEffect(itemId) { + runCatching { api.attachments(workspaceId, itemId) } + .onSuccess { images = it.filter { row -> row.contentType.startsWith("image/") } } + } + + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + saving = true + try { + val bytes = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } + if (bytes == null) { + error = "读不到这张图片,换个来源试试" + return@launch + } + val name = displayName(context, uri) ?: "image-${System.currentTimeMillis()}.png" + val mime = context.contentResolver.getType(uri) ?: "image/png" + val created = api.uploadAttachment(workspaceId, itemId, name, mime, bytes) + images = images + created + // 插入用签名直链:它会过期,所以库里存的是干净的相对路径 + field = insert(field, "![${created.filename}](${created.rawUrl.orEmpty()})\n") + } catch (e: ApiException) { + error = e.message + } catch (e: Exception) { + error = "上传失败:${e.message ?: "未知错误"}" + } finally { + saving = false + } + } + } + + Column( + Modifier + .fillMaxSize() + .background(Paper) + .statusBarsPadding(), + ) { + // 顶部:取消 / 状态 / 预览 / 保存 + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GhostButton("取消", onClick = { onClose(false) }) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + if (preview) "预览" else "编辑", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = Ink, + ) + Text( + "${field.text.length} 字 · Markdown", + fontSize = 11.sp, + color = InkMuted, + ) + } + if (saving) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = Primary) + Spacer(Modifier.width(8.dp)) + } + PrimaryButton(if (preview) "继续写" else "预览", onClick = { preview = !preview }) + Spacer(Modifier.width(8.dp)) + PrimaryButton( + "保存", + enabled = !saving, + onClick = { + scope.launch { + saving = true + error = null + try { + save(if (titleEnabled) title else null, field.text) + onClose(true) + } catch (e: ApiException) { + error = if (e.code == 4009) "刚被别人改过,请退出重进再改一次" else e.message + } catch (e: Exception) { + error = e.message ?: "保存失败" + } finally { + saving = false + } + } + }, + ) + } + HorizontalDivider(color = Line) + + error?.let { + Text( + it, + color = Rust, + fontSize = 13.sp, + modifier = Modifier + .fillMaxWidth() + .background(RustSoft) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) + } + + // 编辑区做成一张纸:标题、工具栏、正文在同一张纸上,边界清楚 + Box( + Modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Column( + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(14.dp)) + .background(Color.White) + .border(1.dp, Line, RoundedCornerShape(14.dp)), + ) { + if (titleEnabled) { + BasicTextField( + value = title, + onValueChange = { title = it }, + singleLine = true, + textStyle = TextStyle( + fontSize = 19.sp, + fontWeight = FontWeight.Bold, + color = Ink, + ), + cursorBrush = SolidColor(Primary), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + decorationBox = { inner -> + if (title.isBlank()) Text("标题", fontSize = 19.sp, color = InkMuted) + inner() + }, + ) + HorizontalDivider(color = Line) + } else if (preview) { + Text( + title.ifBlank { initialTitle }, + fontSize = 19.sp, + fontWeight = FontWeight.Bold, + color = Ink, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + ) + HorizontalDivider(color = Line) + } + + if (preview) { + PreviewBody(field.text, images, Modifier.weight(1f)) + } else { + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ToolButton("B", bold = true) { field = wrap(field, "**") } + ToolButton("I", italic = true) { field = wrap(field, "*") } + ToolButton("标题") { field = prefixLines(field, "## ") } + ToolButton("列表") { field = prefixLines(field, "- ") } + ToolButton("引用") { field = prefixLines(field, "> ") } + ToolButton("代码") { field = wrap(field, "`") } + ToolButton("链接") { field = insert(field, "[文字](https://)") } + ToolButton("图片") { picker.launch("image/*") } + } + HorizontalDivider(color = Line) + + Box( + Modifier + .fillMaxSize() + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + BasicTextField( + value = field, + onValueChange = { field = it }, + textStyle = TextStyle(fontSize = 15.sp, color = Ink, lineHeight = 24.sp), + cursorBrush = SolidColor(Primary), + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + decorationBox = { inner -> + if (field.text.isBlank()) { + Text( + "写点什么。「B」「列表」在光标处插语法,图片直接选会自动上传。", + fontSize = 15.sp, + color = InkMuted, + lineHeight = 24.sp, + ) + } + inner() + }, + ) + } + } + } + } + } +} + +@Composable +private fun PreviewBody( + source: String, + images: List, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val markwon = remember { Markwon.create(context) } + val text = remember(source) { + // 预览里图片单独排一栏(Markwon 核心版不联网取图),正文里的图片语法换成占位说明 + source.replace(Regex("!\\[[^\\]]*\\]\\([^)]*\\)"), "〔图片〕") + } + + Column( + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 14.dp, vertical = 12.dp) + .navigationBarsPadding(), + ) { + if (images.isNotEmpty()) { + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + images.forEach { image -> + AsyncImage( + model = image.rawUrl, + contentDescription = image.filename, + modifier = Modifier + .size(96.dp) + .clip(RoundedCornerShape(10.dp)), + ) + } + } + Spacer(Modifier.height(12.dp)) + } + + if (text.isBlank()) { + Text("还没有内容", fontSize = 14.sp, color = InkMuted) + } else { + AndroidView( + factory = { ctx -> + android.widget.TextView(ctx).apply { + setTextIsSelectable(true) + textSize = 15f + setPadding(0, 0, 0, 0) + setLineSpacing(0f, 1.5f) + setTextColor(android.graphics.Color.parseColor("#4A5854")) + markwon.setMarkdown(this, text) + } + }, + update = { view -> markwon.setMarkdown(view, text) }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + ) + } + } +} + +/** 工具条按钮:小、方、无多余留白,横滑容得下。 */ +@Composable +private fun ToolButton( + label: String, + bold: Boolean = false, + italic: Boolean = false, + onClick: () -> Unit, +) { + Box( + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(PaperSunken) + .clickable { onClick() } + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + label, + fontSize = 13.sp, + color = InkSoft, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Medium, + fontStyle = if (italic) FontStyle.Italic else null, + ) + } +} + +/** 把选区包起来:没选就插一对并落光标在中间,选了就把选中的包进去。 */ +private fun wrap(field: TextFieldValue, token: String): TextFieldValue { + val start = field.selection.start.coerceIn(0, field.text.length) + val end = field.selection.end.coerceIn(start, field.text.length) + val picked = field.text.substring(start, end) + val next = field.text.substring(0, start) + token + picked + token + field.text.substring(end) + val caret = if (picked.isEmpty()) start + token.length else start + picked.length + token.length * 2 + return field.copy(text = next, selection = TextRange(caret)) +} + +/** 行首插前缀(标题/列表/引用),一次处理选区覆盖的每一行。 */ +private fun prefixLines(field: TextFieldValue, prefix: String): TextFieldValue { + val start = field.selection.start.coerceIn(0, field.text.length) + val end = field.selection.end.coerceIn(start, field.text.length) + val lineStart = field.text.lastIndexOf('\n', (start - 1).coerceAtLeast(0)).let { + if (it < 0 || start == 0) 0 else it + 1 + } + val before = field.text.substring(0, lineStart) + val block = field.text.substring(lineStart, end) + val after = field.text.substring(end) + val nextBlock = block.split('\n').joinToString("\n") { line -> + if (line.startsWith(prefix)) line else prefix + line + } + val next = before + nextBlock + after + return field.copy(text = next, selection = TextRange(lineStart + nextBlock.length)) +} + +private fun insert(field: TextFieldValue, snippet: String): TextFieldValue { + val start = field.selection.start.coerceIn(0, field.text.length) + val end = field.selection.end.coerceIn(start, field.text.length) + val next = field.text.substring(0, start) + snippet + field.text.substring(end) + return field.copy(text = next, selection = TextRange(start + snippet.length)) +} + +private fun displayName(context: Context, uri: Uri): String? = runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } +}.getOrNull() diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt b/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt index 1d7788f..9e2cd5c 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt @@ -66,6 +66,8 @@ fun TaskDetailSheet( itemId: String, onClose: () -> Unit, onChanged: () -> Unit, + /** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */ + onEdit: (ItemDetail) -> Unit = {}, ) { val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -125,6 +127,8 @@ fun TaskDetailSheet( ) Spacer(Modifier.width(6.dp)) if (d.kind == "ticket") SoftPill("工单", Clay, ClaySoft) + Spacer(Modifier.weight(1f)) + GhostButton("编辑正文", onClick = { onEdit(d) }) } Spacer(Modifier.height(8.dp)) OutlinedTextField( diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt index 1a938ff..1a60f04 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt @@ -51,7 +51,7 @@ private val views = listOf( ) @Composable -fun TaskListScreen(api: Api, workspace: Workspace) { +fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) { val scope = rememberCoroutineScope() var view by remember { mutableStateOf("active") } var mode by remember { mutableStateOf("list") } @@ -193,6 +193,10 @@ fun TaskListScreen(api: Api, workspace: Workspace) { itemId = id, onClose = { openId = null }, onChanged = { scope.launch { reload() } }, + onEdit = { + openId = null + onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version)) + }, ) } } diff --git a/backend/app/api/attachments.py b/backend/app/api/attachments.py index 6685a0b..7a7f410 100644 --- a/backend/app/api/attachments.py +++ b/backend/app/api/attachments.py @@ -28,7 +28,7 @@ from app.models_attachment import Attachment from app.models_task import Item from app.schemas import ok from app.security import decode_token -from app.services import storage +from app.services import signing, storage router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"]) @@ -36,6 +36,7 @@ ALLOWED_KINDS = ("task", "ticket", "knowledge") def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict: + settings = get_settings() return { "id": str(row.id), "item_id": str(row.item_id), @@ -45,6 +46,11 @@ def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict: "sha256": row.sha256, "uploader_id": str(row.uploader_id), "created_at": row.created_at.isoformat(), + # 图片直链:自带短期签名,浏览器 直接能用,不需要把登录令牌塞进 URL + "raw_url": ( + f"{settings.api_prefix}/workspaces/{row.workspace_id}" + f"/attachments/{row.id}/raw?{signing.sign(row.workspace_id, row.id)}" + ), "can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"), } @@ -176,31 +182,40 @@ async def raw_attachment( ws_id: uuid.UUID, session: SessionDep, attachment_id: uuid.UUID, - token: Annotated[str, Query(description="access token( 带不了请求头)")], + exp: Annotated[int | None, Query(description="签名过期时间(unix 秒)")] = None, + sig: Annotated[str | None, Query(description="短期签名,见 services/signing.py")] = None, + token: Annotated[str | None, Query(description="兼容旧链接用的 access token")] = None, ): - """给 Markdown 里的 `![](…/raw?token=…)` 用:浏览器 无法附加 Authorization 头。 + """给 Markdown 里的图片用:浏览器 `` 无法附加 Authorization 头。 - token 走 query 是 Web 上的通行做法,但要接受它的代价:会进访问日志与浏览器历史。 - 所以这个接口**只读、只给本工作区成员**,且不返回 Content-Disposition(不当下载用)。 + 优先认 `?exp=&sig=` 的短期签名(由附件列表接口签发,只为这一张图、只活 30 分钟); + 老链接里的 `?token=` 仍能用,但那是过渡期兼容,前端已不再生成。 """ - try: - user_id = decode_token(token, "access") - except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理 - raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None + if exp is not None or sig is not None: + if not signing.verify(ws_id, attachment_id, exp, sig): + raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "图片链接已失效,请刷新页面") + # 这个接口不走 MemberDep,RLS 不会自动绑租户,得手动绑一次 + await bind_tenant(session, ws_id=ws_id, user_id=None) + else: + if not token: + raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问凭据") + try: + user_id = decode_token(token, "access") + except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理 + raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None - # 这个接口没有走 MemberDep(令牌在 query 里),RLS 不会自动绑租户,得手动绑一次 - await bind_tenant(session, ws_id=ws_id, user_id=user_id) + await bind_tenant(session, ws_id=ws_id, user_id=user_id) - member = await session.scalar( - select(Member.id).where( - Member.workspace_id == ws_id, - Member.user_id == user_id, - Member.status == "active", + member = await session.scalar( + select(Member.id).where( + Member.workspace_id == ws_id, + Member.user_id == user_id, + Member.status == "active", + ) ) - ) - if member is None: - # 不是成员就当作不存在:不暴露"这里有个附件但你看不到" - raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在") + if member is None: + # 不是成员就当作不存在:不暴露"这里有个附件但你看不到" + raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在") row = await session.scalar( select(Attachment).where(Attachment.id == attachment_id, Attachment.workspace_id == ws_id) diff --git a/backend/app/config.py b/backend/app/config.py index 8755cc3..e5248de 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -67,6 +67,9 @@ class Settings(BaseSettings): upload_dir: str = "var/uploads" # 单文件上限。20MB 够放文档与截图;再大就该走对象存储 + 分片了。 max_upload_mb: int = 20 + # 图片直链的签名有效期。短一点更安全,太长等于把凭据长期贴在页面上; + # 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。 + attachment_url_ttl_seconds: int = 1800 # ── 知识库向量库(独立实例,M3 起用)─────────────────── # 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它; diff --git a/backend/app/services/signing.py b/backend/app/services/signing.py new file mode 100644 index 0000000..4623d9e --- /dev/null +++ b/backend/app/services/signing.py @@ -0,0 +1,45 @@ +"""附件直链的短期签名。 + +为什么需要它:Markdown 里的图片是 ``,浏览器不会给它加 Authorization 头, +所以图片地址必须自带凭据。把**登录令牌**塞进 query 是最省事的做法,但代价明确: +令牌会进 Nginx access log、浏览器历史、以及任何复制粘贴出去的链接里,有效期还长达 30 分钟。 + +这里换成一个只为"这一张图"服务、只活 30 分钟的签名: +`?exp=&sig=`。它不能用来调任何别的接口,泄漏了也只影响一张图的一小段时间。 +""" + +import hashlib +import hmac +import time +import uuid + +from app.config import get_settings + +# 域分离:即使有人拿 JWT 密钥来试,也拿不到同样的签名(反之亦然) +_DOMAIN = b"attachment-raw-v1" + + +def _key() -> bytes: + base = get_settings().jwt_secret.encode("utf-8") + return hmac.new(base, _DOMAIN, hashlib.sha256).digest() + + +def _digest(workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int) -> str: + # 工作区也签进去,避免拿 A 工作区的签名去读 B 工作区的文件 + payload = f"{workspace_id}:{attachment_id}:{exp}".encode() + return hmac.new(_key(), payload, hashlib.sha256).hexdigest()[:32] + + +def sign(workspace_id: uuid.UUID, attachment_id: uuid.UUID, ttl_seconds: int | None = None) -> str: + """返回可直接拼在 `/raw` 后面的查询串。""" + ttl = ttl_seconds if ttl_seconds is not None else get_settings().attachment_url_ttl_seconds + exp = int(time.time()) + ttl + return f"exp={exp}&sig={_digest(workspace_id, attachment_id, exp)}" + + +def verify(workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int | None, sig: str | None) -> bool: + if not exp or not sig: + return False + if exp < int(time.time()): # 过期就是过期,不给宽限 + return False + return hmac.compare_digest(_digest(workspace_id, attachment_id, exp), sig) diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py index df33e7a..bc8c9dd 100644 --- a/backend/tests/test_attachments.py +++ b/backend/tests/test_attachments.py @@ -13,7 +13,10 @@ from typing import Any import pytest from httpx import AsyncClient +import uuid + from app.config import get_settings +from app.services import signing from .conftest import create_workspace, login, unwrap @@ -160,11 +163,66 @@ async def test_raw_image_url_takes_token_in_query(client: AsyncClient) -> None: assert resp.headers["content-disposition"] == "inline" assert resp.headers["cache-control"].startswith("private") - # 不带 token 直接 422(参数必填),带了别人的 token 一律当不存在 + # 既没签名也没令牌:401(而不是把"缺凭据"说成"文件不存在") missing = await client.get(f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw") - assert missing.status_code == 422 + assert missing.status_code == 401 + assert missing.json()["code"] == 2001 outsider = await login(client) leaked = await client.get( f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={outsider['token']}" ) assert leaked.json()["code"] == 8001 + + +async def test_raw_url_uses_short_lived_signature(client: AsyncClient) -> None: + """图片直链自带短期签名:不需要把登录令牌贴进 URL,也不需要 Authorization 头。""" + user, ws, item_id = await _ws_with_item(client) + body = b"\x89PNG\r\n\x1a\n signed png" + created = unwrap(await _upload(client, user, ws["id"], item_id, body, "签名图.png")) + + listed = unwrap( + await client.get( + f"{BASE}/workspaces/{ws['id']}/items/{item_id}/attachments", headers=user["headers"] + ) + ) + raw_url = listed["items"][0]["raw_url"] + assert "exp=" in raw_url and "sig=" in raw_url + assert "token=" not in raw_url # 不该再把登录令牌塞进 URL + + # 裸请求(无 Authorization、无 cookie)也要能取到图——这才是 的真实处境 + resp = await client.get(raw_url) + assert resp.status_code == 200 + assert resp.content == body + + +async def test_raw_url_rejects_tampered_or_expired_signature(client: AsyncClient) -> None: + user, ws, item_id = await _ws_with_item(client) + created = unwrap(await _upload(client, user, ws["id"], item_id, b"data", "图.png")) + base = f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw" + + # 改一个字符就该失效:签名不是"看起来对就行" + good = signing.sign(uuid.UUID(ws["id"]), uuid.UUID(created["id"])) + tampered = good[:-1] + ("0" if good[-1] != "0" else "1") + bad = await client.get(f"{base}?{tampered}") + assert bad.status_code == 403 + assert bad.json()["code"] == 8003 + + # 过期签名:就算 HMAC 算得对,时间过了也不给 + stale = signing.sign(uuid.UUID(ws["id"]), uuid.UUID(created["id"]), ttl_seconds=-10) + expired = await client.get(f"{base}?{stale}") + assert expired.status_code == 403 + assert expired.json()["code"] == 8003 + + +async def test_raw_url_signature_is_workspace_scoped(client: AsyncClient) -> None: + """拿 A 工作区签出来的链接去请求 B 工作区,必须失败(签名里带 workspace_id)。""" + user, ws_a, item_a = await _ws_with_item(client, "甲工作区") + ws_b = await create_workspace(client, user, "乙工作区") + created = unwrap(await _upload(client, user, ws_a["id"], item_a, b"data", "图.png")) + + query = signing.sign(uuid.UUID(ws_a["id"]), uuid.UUID(created["id"])) + crossed = await client.get( + f"{BASE}/workspaces/{ws_b['id']}/attachments/{created['id']}/raw?{query}" + ) + assert crossed.status_code == 403 + assert crossed.json()["code"] == 8003 \ No newline at end of file diff --git a/web/e2e/richtext.mjs b/web/e2e/richtext.mjs index c91b067..caf2a41 100644 --- a/web/e2e/richtext.mjs +++ b/web/e2e/richtext.mjs @@ -3,8 +3,8 @@ * * 走真浏览器而不是只测接口,因为要验的正是四件"只有真渲染才看得见"的事: * 1. 输 `**粗体**` 之后编辑器里真的变成了 (IR 模式的输入规则生效); - * 2. 上传的图片在编辑器里能显示(说明 /raw 直链带对了令牌); - * 3. 存进库的正文是**干净**的(不含 ?token=),否则几天后满篇红叉; + * 2. 上传的图片在编辑器里能显示(说明 /raw 直链带对了签名); + * 3. 存进库的正文是**干净**的(不含 ?exp=/?sig=),否则几天后满篇红叉; * 4. 重新打开抽屉,正文与附件都还在(证明落库而不是活在内存)。 */ import { chromium } from "playwright-core"; @@ -123,7 +123,8 @@ try { const saved = await call("GET", `/workspaces/${ws.id}/items/${item.id}`, null, token); const body = String(saved.body_md ?? ""); check("正文已落库", body.includes("必须") && body.includes("财务口径"), body.slice(0, 40)); - check("落库正文不含令牌", !body.includes("token="), body.includes("token=") ? "含 token=" : ""); + const leaked = ["token=", "exp=", "sig="].filter((needle) => body.includes(needle)); + check("落库正文不含凭据(token/exp/sig)", leaked.length === 0, leaked.join(",")); // —— 3. 编辑器插入图片(走的是粘贴/拖拽同一段上传逻辑)—— const uploadInput = drawer.locator('.vditor input[type="file"]'); @@ -134,7 +135,11 @@ try { await img.waitFor({ timeout: 10000 }); const src = (await img.getAttribute("src")) ?? ""; check("上传后编辑器里出现图片", Boolean(src), src.slice(0, 60)); - check("图片走带令牌的 /raw 直链", src.includes("/raw?token="), src.slice(0, 60)); + check( + "图片走带签名的 /raw 直链", + src.includes("/raw?") && (src.includes("sig=") || src.includes("token=")), + src.slice(0, 80), + ); await page.waitForTimeout(800); check("图片资源能取到(/raw 返回 200)", rawHits.includes(200), rawHits.join(",")); @@ -164,6 +169,24 @@ try { } check("重新打开后附件仍在", stillThere); + // —— 6. 只读渲染里的表格:宽表能横向滚动,不被挤成一坨 —— + const tableMd = [ + "| 项目 | 金额 | 说明 |", + "| --- | --- | --- |", + "| 打印费 | 12.00 | 电子发票 |", + "| 快递费 | 23.50 | 到付 |", + ].join("\n"); + await call("POST", `/workspaces/${ws.id}/items/${item.id}/comments`, { body_md: tableMd }, token); + await drawer2.getByRole("button", { name: "关闭" }).click(); + await page.getByRole("button", { name: "整理发票原件", exact: true }).click(); + const drawer3 = page.getByRole("dialog"); + await drawer3.waitFor(); + const table = drawer3.locator(".prose-dadao table").first(); + await table.waitFor({ timeout: 10000 }); + check("评论区表格渲染出来了", (await drawer3.locator(".prose-dadao td").count()) >= 4); + const box = await table.boundingBox(); + check("表格没有撑破抽屉宽度", box !== null && box.width <= 900, `${box?.width ?? "无"} px`); + const realProblems = problems.filter((p) => !p.includes("favicon")); check("控制台与网络无报错", realProblems.length === 0, realProblems.slice(0, 3).join(" | ")); } catch (err) { diff --git a/web/src/components/Markdown.tsx b/web/src/components/Markdown.tsx index dc54050..de5106c 100644 --- a/web/src/components/Markdown.tsx +++ b/web/src/components/Markdown.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; +import type { UrlResources } from "../lib/attachmentUrls"; import { renderMarkdown } from "../lib/markdown"; interface Props { @@ -7,6 +8,8 @@ interface Props { /** 列表里只给个开头时用它压小字号 */ compact?: boolean; className?: string; + /** 附件 id → 当前可访问地址(后端签发的 raw_url) */ + resources?: UrlResources; } /** @@ -15,8 +18,8 @@ interface Props { * renderMarkdown 内部已经把原文全部 HTML 转义过再套标签, * 这里放心用 dangerouslySetInnerHTML(这是本仓库唯一允许用的地方)。 */ -export function Markdown({ source, compact = false, className = "" }: Props) { - const html = useMemo(() => renderMarkdown(source ?? ""), [source]); +export function Markdown({ source, compact = false, className = "", resources }: Props) { + const html = useMemo(() => renderMarkdown(source ?? "", resources), [source, resources]); if (!source?.trim()) { return

还没有内容。

; diff --git a/web/src/components/RichTextEditor.tsx b/web/src/components/RichTextEditor.tsx index 7f90de5..b417d81 100644 --- a/web/src/components/RichTextEditor.tsx +++ b/web/src/components/RichTextEditor.tsx @@ -2,12 +2,14 @@ import { useEffect, useRef, useState } from "react"; import type { IVditorInstance } from "vditor/dist/index.js"; -import { stripAttachmentTokens, tokenizeAttachmentUrls } from "../lib/attachmentUrls"; -import { attachmentUrl, uploadFile } from "../lib/client"; +import { applyAttachmentUrls, stripAttachmentTokens, type UrlResources } from "../lib/attachmentUrls"; +import { canonicalAttachmentPath, uploadFile } from "../lib/client"; interface Attachment { id: string; filename: string; + /** 后端签发的直链(带短期签名),用来在编辑器里直接显示刚传的图 */ + raw_url?: string; } interface Props { @@ -23,6 +25,10 @@ interface Props { minHeight?: number; ariaLabel: string; onError?: (message: string) => void; + /** 附件 id → 可访问地址;编辑器渲染图片时优先用它(签名比令牌安全且会轮换) */ + resources?: UrlResources; + /** 编辑器里贴图上传成功后通知外层,好让附件面板立刻多一条 */ + onUploaded?: (attachment: Attachment) => void; } /** @@ -44,6 +50,8 @@ export function RichTextEditor({ minHeight = 240, ariaLabel, onError, + resources, + onUploaded, }: Props) { const hostRef = useRef(null); const instRef = useRef(null); @@ -51,12 +59,16 @@ export function RichTextEditor({ const valueRef = useRef(value); const onChangeRef = useRef(onChange); const onErrorRef = useRef(onError); + const resourcesRef = useRef(resources); + const onUploadedRef = useRef(onUploaded); const [ready, setReady] = useState(false); const [uploading, setUploading] = useState(false); valueRef.current = value; onChangeRef.current = onChange; onErrorRef.current = onError; + resourcesRef.current = resources; + onUploadedRef.current = onUploaded; const disabledRef = useRef(disabled); disabledRef.current = disabled; @@ -76,7 +88,7 @@ export function RichTextEditor({ const instance = new Vditor(host, { cdn: "/vditor", // 本地托管 lute/i18n/icon,不走公网 CDN(见 web/public/vditor) mode: "ir", - value: tokenizeAttachmentUrls(valueRef.current), + value: applyAttachmentUrls(valueRef.current, resourcesRef.current), height: minHeight, placeholder, theme: "classic", @@ -132,7 +144,10 @@ export function RichTextEditor({ ); // 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会 // tip.show(字符串) 当成提示语显示,并不会把内容插进正文。 - instRef.current?.insertMD(`\n![](${attachmentUrl(wsId, created.id)})\n`); + const src = + created.raw_url ?? canonicalAttachmentPath(wsId, created.id); + instRef.current?.insertMD(`\n![](${src})\n`); + onUploadedRef.current?.(created); } return null; } catch (error) { @@ -179,7 +194,7 @@ export function RichTextEditor({ if (!inst) return; const current = stripAttachmentTokens(inst.getValue()); if (current !== value) { - inst.setValue(tokenizeAttachmentUrls(value), true); + inst.setValue(applyAttachmentUrls(value, resourcesRef.current), true); } }, [value, ready]); diff --git a/web/src/components/TaskDetail.tsx b/web/src/components/TaskDetail.tsx index d3e925e..34b181a 100644 --- a/web/src/components/TaskDetail.tsx +++ b/web/src/components/TaskDetail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "../App"; import { ApiError, api } from "../lib/client"; import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse"; @@ -88,6 +88,14 @@ export function TaskDetail({ const [due, setDue] = useState(""); const [comments, setComments] = useState([]); const [attachments, setAttachments] = useState([]); + /** 附件 id → 带签名的直链:正文里的图片靠它显示,不需要把登录令牌写进内容 */ + const resources = useMemo(() => { + const map: Record = {}; + for (const row of attachments) { + if (row.raw_url) map[row.id] = row.raw_url; + } + return map; + }, [attachments]); const [draft, setDraft] = useState(""); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -402,7 +410,7 @@ export function TaskDetail({

验收结论

- +
) : null} @@ -497,6 +505,12 @@ export function TaskDetail({ itemId={item.id} value={body} onChange={setBody} + resources={resources} + onUploaded={(created) => + setAttachments((prev) => + prev.some((row) => row.id === created.id) ? prev : [...prev, created as never], + ) + } onError={(message) => setError(message)} disabled={busy} /> @@ -605,7 +619,7 @@ export function TaskDetail({ )}
- +

{new Date(c.created_at).toLocaleString("zh-CN")}

diff --git a/web/src/index.css b/web/src/index.css index dd2ace3..81fbfff 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -261,16 +261,31 @@ border: 1px solid theme("colors.line.100"); } +/* 表格:宽表横向滚动(不撑破抽屉),窄表铺满 */ .prose-dadao table { - width: 100%; + display: block; + width: max-content; + min-width: 100%; + max-width: 100%; + overflow-x: auto; border-collapse: collapse; font-size: 0.9em; + margin: 0.6rem 0; } .prose-dadao th, .prose-dadao td { border: 1px solid theme("colors.line.200"); - padding: 0.35rem 0.5rem; + padding: 0.4rem 0.6rem; text-align: left; + white-space: nowrap; +} +.prose-dadao th { + background: theme("colors.paper.200"); + font-weight: 600; + color: theme("colors.ink.800"); +} +.prose-dadao tbody tr:nth-child(even) td { + background: theme("colors.paper.50"); } .prose-compact { diff --git a/web/src/lib/attachmentUrls.ts b/web/src/lib/attachmentUrls.ts index 45fd661..bbf03cc 100644 --- a/web/src/lib/attachmentUrls.ts +++ b/web/src/lib/attachmentUrls.ts @@ -1,39 +1,45 @@ /** - * 附件直链的令牌处理。 + * 附件直链的地址处理。 * - * 库里存的 Markdown 必须是"干净"的:只记 `/api/v1/.../attachments//raw`。 - * 原因有两条—— - * 1. `` 发不出 Authorization 头,图片只能靠 query 里的 token 鉴权; - * 2. token 会过期。要是把它写进正文,几天后整篇文档的图片全变红叉, - * 而且团队里每个人拿到的 token 还不一样,同一个字段没法共用。 + * 库里存的 Markdown 必须是"干净"的:只记 `/api/v1/.../attachments//raw`,不带任何查询串。 + * 原因有三条—— + * 1. `` 发不出 Authorization 头,图片只能靠 URL 自身携带凭据; + * 2. 凭据会过期(令牌 30 分钟、签名也是 30 分钟),写进正文就会几天后满篇红叉; + * 3. 同一个字段在团队里要能共用,而每个人的令牌不一样。 * - * 所以规则是:**存的时候剥掉,看的时候现加**。token 永远是当前会话的。 + * 所以规则是:**存的时候剥干净,看的时候现配**。 + * 优先用后端在附件列表里签好的 `raw_url`(短期签名,别人拿到也只能看这一张图的一小会儿), + * 没拿到签名时才退回令牌(兼容老链接,前端已不再主动生成)。 */ import { session } from "./client"; const UUID = "[0-9a-fA-F-]{36}"; -/** 已经带上(或没带)token 的附件直链 */ -const RAW_URL = new RegExp(`(/api/v1/workspaces/${UUID}/attachments/${UUID}/raw)(?:\\?token=[^)\"'\\s]*)?`, "g"); +/** 附件直链:可能带 token 或 exp/sig,全部视为"要替换的查询串" */ +const RAW_URL = new RegExp(`(/api/v1/workspaces/${UUID}/attachments/(${UUID})/raw)(?:\\?[^)"'\\s]*)?`, "g"); + +/** 附件 id → 可访问地址(后端签发的 raw_url) */ +export type UrlResources = Record; function tokenSuffix(): string { const token = session.access; return token ? `?token=${encodeURIComponent(token)}` : ""; } -/** 保存前调用:把正文里所有 /raw?token=... 的令牌去掉。 */ +/** 保存前调用:把正文里所有直链的查询串去掉。 */ export function stripAttachmentTokens(source: string): string { return (source ?? "").replace(RAW_URL, (_all, url: string) => url); } -/** 渲染或加载进编辑器前调用:给所有 /raw 链接补上当前令牌。 */ -export function tokenizeAttachmentUrls(source: string): string { - const suffix = tokenSuffix(); - if (!suffix) return stripAttachmentTokens(source); - return (source ?? "").replace(RAW_URL, (_all, url: string) => `${url}${suffix}`); +/** 渲染 / 装载前调用:按附件 id 换成当前可用的地址。 */ +export function applyAttachmentUrls(source: string, resources?: UrlResources): string { + const fallback = tokenSuffix(); + return (source ?? "").replace(RAW_URL, (_all, url: string, _ws: string, id: string) => { + const signed = resources?.[id]; + if (signed) return signed; + return fallback ? `${url}${fallback}` : url; + }); } -/** 只处理 HTML 里的 img/src 与 a/href(已渲染的预览用)。 */ -export function tokenizeAttachmentHtml(html: string): string { - return tokenizeAttachmentUrls(html); -} +/** 兼容旧名字:只处理 HTML(已渲染的预览用),逻辑同 applyAttachmentUrls。 */ +export const tokenizeAttachmentHtml = applyAttachmentUrls; diff --git a/web/src/lib/client.ts b/web/src/lib/client.ts index 807ac05..d00bf8d 100644 --- a/web/src/lib/client.ts +++ b/web/src/lib/client.ts @@ -145,7 +145,7 @@ export async function uploadFile(path: string, file: File): Promise { * 附件直链:`` 带不了 Authorization 头,所以令牌走 query。 * 只在渲染 Markdown 图片时用,不要拿它去做普通的下载链接(那样 token 会留在浏览器历史里)。 */ -export function attachmentUrl(wsId: string, attachmentId: string): string { - const token = session.access ?? ""; - return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw?token=${encodeURIComponent(token)}`; +/** 入库用的规范地址:不带任何查询串,抬头签名由渲染层按附件列表现配。 */ +export function canonicalAttachmentPath(wsId: string, attachmentId: string): string { + return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw`; } diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts index 1397e85..bb5a6fe 100644 --- a/web/src/lib/markdown.ts +++ b/web/src/lib/markdown.ts @@ -12,7 +12,7 @@ import DOMPurify from "dompurify"; import MarkdownIt from "markdown-it"; -import { tokenizeAttachmentHtml } from "./attachmentUrls"; +import { applyAttachmentUrls, type UrlResources } from "./attachmentUrls"; const md = new MarkdownIt({ html: false, // 不解析原始 HTML @@ -55,13 +55,13 @@ md.renderer.rules.image = (tokens, idx, options, env, self) => { }; /** 渲染成可直接塞进 innerHTML 的安全 HTML。 */ -export function renderMarkdown(source: string): string { +export function renderMarkdown(source: string, resources?: UrlResources): string { const safe = DOMPurify.sanitize(md.render(source ?? ""), { // 图片走我们自己的 /raw 直链;data: 只放行图片,别的都去掉 ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|#|data:image\/)/i, }); - // 库里存的是不带令牌的直链,渲染这一层再补上(见 lib/attachmentUrls.ts) - return tokenizeAttachmentHtml(safe); + // 库里存的是干净的直链,渲染这一层按附件列表换成带签名的地址(见 lib/attachmentUrls.ts) + return applyAttachmentUrls(safe, resources); } /** 纯文本摘要,用于列表里的一行预览(去掉标记符号,压缩空白)。 */ diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3a92f54..8190f67 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -89,6 +89,8 @@ export interface Attachment { size: number; uploader_id: string; created_at: string; + /** 后端签发的短期直链(带 exp/sig),正文图片直接用它 */ + raw_url?: string; can_delete: boolean; } diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx index dd2d23f..8207e98 100644 --- a/web/src/pages/Knowledge.tsx +++ b/web/src/pages/Knowledge.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { AppShell } from "../components/AppShell"; import { AttachmentPanel } from "../components/AttachmentPanel"; @@ -302,6 +302,14 @@ function KnowledgeDrawer({ const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [attachments, setAttachments] = useState([]); + /** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */ + const resources = useMemo(() => { + const map: Record = {}; + for (const row of attachments) { + if (row.raw_url) map[row.id] = row.raw_url; + } + return map; + }, [attachments]); const [busy, setBusy] = useState(false); const load = useCallback(async () => { @@ -459,6 +467,14 @@ function KnowledgeDrawer({ itemId={doc.id} value={body} onChange={setBody} + resources={resources} + onUploaded={(created) => + setAttachments((prev) => + prev.some((row) => row.id === created.id) + ? prev + : [...prev, created as unknown as Attachment], + ) + } onError={(message) => setError(message)} disabled={!canEdit} />