- README:富文本行改成「Web Vditor 编辑 / 安卓 Markwon 预览编辑」,e2e 15 项 - ROADMAP 第 5 节:勾掉表格与超过一两行的旧描述(表格是只读渲染, HTML 片段仍按文本显示);附件改为短期签名 raw_url;安卓从"未做"改成已完成 - 记下仍欠的三件事:保存后回列表而不是回详情、图片压缩/缩略图、离线缓存 验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿; tsc -b 0 错;vite build 通过;安卓 assembleDebug 与模拟器逐屏走通。
This commit is contained in:
+20
-11
@@ -396,31 +396,40 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
|
||||
正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。
|
||||
理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。
|
||||
|
||||
图片不是 base64 塞进正文,而是走附件接口拿一个稳定地址:
|
||||
图片不是 base64 塞进正文,而是走附件接口拿地址。**库里存的是不带凭据的相对路径**:
|
||||
|
||||
```markdown
|
||||

|
||||

|
||||
```
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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=<access>` | 直链,给 `<img src>` 用 |
|
||||
| GET | `/workspaces/{ws_id}/attachments/{id}/raw?exp=&sig=` | 直链,给 `<img src>` 用(签名见 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 图片直链:短期签名,而不是把令牌写进正文
|
||||
|
||||
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文会膨胀几十倍,且破坏检索),
|
||||
或者给一个生命周期短的签名地址。
|
||||
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
||||
或者给一个短期签名地址。取后者:
|
||||
|
||||
当前实现取的是"query 里带 access token",**并且只允许本工作区成员访问**(非成员一律 `8001`,
|
||||
不区分"不存在"与"没权限",避免探测他人附件)。这是本地/内网阶段的折中,上生产前应换成
|
||||
**带过期时间的一次性签名 URL**,见 `ROADMAP` 的 M7 安全清单。
|
||||
- `raw_url` 形如 `…/raw?exp=1730000000&sig=<hmac>`,签名载荷是 `workspace_id:attachment_id:exp`,
|
||||
密钥从 `JWT_SECRET` 域分离派生(`attachment-raw-v1`),不与登录令牌共用。
|
||||
- 校验只看签名与过期时间,**不需要用户已登录**;签名里绑了 `workspace_id`,A 工作区签的链接
|
||||
打到 B 工作区会被拒(`8003`)。
|
||||
- 过期不宽限,刷新页面即可拿到新链接。
|
||||
- 兼容旧路径:不带 `sig` 时仍可用 `?token=<access>` 走成员校验,供老客户端过渡。
|
||||
|
||||
为什么非要这样:令牌写进正文会落库,几天后过期就是满篇红叉;用户换设备后旧令牌失效,图片全挂。
|
||||
把凭据放在"每次渲染时现签"的 URL 上,正文才能长期存活。
|
||||
|
||||
### 6.3 附件错误码
|
||||
|
||||
### 6.3 附件错误码
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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<Workspace?>(null) }
|
||||
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
||||
var unread by remember { mutableStateOf(0) }
|
||||
// 打开富文本编辑器时非空;关掉就恢复原来的界面
|
||||
var editing by remember { mutableStateOf<EditRequest?>(null) }
|
||||
|
||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||
LaunchedEffect(screen) {
|
||||
@@ -122,6 +127,7 @@ private fun AppRoot(joinUri: String?) {
|
||||
if (ws == null) {
|
||||
screen = Screen.Home
|
||||
} else {
|
||||
Box {
|
||||
WorkspaceScaffold(
|
||||
workspace = ws,
|
||||
tab = tab,
|
||||
@@ -130,12 +136,55 @@ private fun AppRoot(joinUri: String?) {
|
||||
onBackHome = { screen = Screen.Home },
|
||||
) {
|
||||
when (tab) {
|
||||
WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Tasks -> TaskListScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
onEdit = { editing = it },
|
||||
)
|
||||
|
||||
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Knowledge -> KnowledgeScreen(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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Tag> = emptyList(),
|
||||
val version: Int = 1,
|
||||
val activities: List<Activity> = emptyList(),
|
||||
val attachments: List<Attachment> = 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<Attachment> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class Invitation(
|
||||
val code: String,
|
||||
@@ -223,6 +246,7 @@ data class KnowledgeDoc(
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val knowledge: KnowledgeInfo = KnowledgeInfo(),
|
||||
val source: SourceRef? = null,
|
||||
val attachments: List<Attachment> = 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<Attachment> =
|
||||
call<AttachmentPage>(
|
||||
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<Envelope<Attachment>>(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。 */
|
||||
|
||||
@@ -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<List<KnowledgeDoc>>(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 {
|
||||
|
||||
@@ -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<String?>(null) }
|
||||
var images by remember { mutableStateOf(emptyList<Attachment>()) }
|
||||
|
||||
// 传过的图片:进来先拉一份,预览区与「图片」栏都用它
|
||||
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, "})\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<Attachment>,
|
||||
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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
# 图片直链:自带短期签名,浏览器 <img> 直接能用,不需要把登录令牌塞进 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,19 +182,28 @@ async def raw_attachment(
|
||||
ws_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
attachment_id: uuid.UUID,
|
||||
token: Annotated[str, Query(description="access token(<img src> 带不了请求头)")],
|
||||
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 里的 `` 用:浏览器 <img> 无法附加 Authorization 头。
|
||||
"""给 Markdown 里的图片用:浏览器 `<img>` 无法附加 Authorization 头。
|
||||
|
||||
token 走 query 是 Web 上的通行做法,但要接受它的代价:会进访问日志与浏览器历史。
|
||||
所以这个接口**只读、只给本工作区成员**,且不返回 Content-Disposition(不当下载用)。
|
||||
优先认 `?exp=&sig=` 的短期签名(由附件列表接口签发,只为这一张图、只活 30 分钟);
|
||||
老链接里的 `?token=` 仍能用,但那是过渡期兼容,前端已不再生成。
|
||||
"""
|
||||
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)
|
||||
|
||||
member = await session.scalar(
|
||||
|
||||
@@ -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,也不该为了装扩展去动它;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""附件直链的短期签名。
|
||||
|
||||
为什么需要它:Markdown 里的图片是 `<img src="…">`,浏览器不会给它加 Authorization 头,
|
||||
所以图片地址必须自带凭据。把**登录令牌**塞进 query 是最省事的做法,但代价明确:
|
||||
令牌会进 Nginx access log、浏览器历史、以及任何复制粘贴出去的链接里,有效期还长达 30 分钟。
|
||||
|
||||
这里换成一个只为"这一张图"服务、只活 30 分钟的签名:
|
||||
`?exp=<unix 秒>&sig=<hmac 前 32 位>`。它不能用来调任何别的接口,泄漏了也只影响一张图的一小段时间。
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -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)也要能取到图——这才是 <img> 的真实处境
|
||||
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
|
||||
+27
-4
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* 走真浏览器而不是只测接口,因为要验的正是四件"只有真渲染才看得见"的事:
|
||||
* 1. 输 `**粗体**` 之后编辑器里真的变成了 <strong>(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) {
|
||||
|
||||
@@ -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 <p className={`text-sm text-ink-400 ${className}`}>还没有内容。</p>;
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const instRef = useRef<IVditorInstance | null>(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})\n`);
|
||||
const src =
|
||||
created.raw_url ?? canonicalAttachmentPath(wsId, created.id);
|
||||
instRef.current?.insertMD(`\n\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]);
|
||||
|
||||
|
||||
@@ -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<Comment[]>([]);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
/** 附件 id → 带签名的直链:正文里的图片靠它显示,不需要把登录令牌写进内容 */
|
||||
const resources = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
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<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
@@ -402,7 +410,7 @@ export function TaskDetail({
|
||||
<div className="mt-3 rounded-lg border border-line-200 bg-white p-3">
|
||||
<p className="text-2xs text-ink-400">验收结论</p>
|
||||
<div className="mt-1">
|
||||
<Markdown source={ticket.resolution_md} compact />
|
||||
<Markdown source={ticket.resolution_md} resources={resources} compact />
|
||||
</div>
|
||||
</div>
|
||||
) : 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({
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 rounded-xl rounded-tl-sm bg-paper-100 px-3 py-2">
|
||||
<Markdown source={c.body_md} />
|
||||
<Markdown source={c.body_md} resources={resources} />
|
||||
<p className="mt-0.5 text-2xs text-ink-400">
|
||||
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
|
||||
+17
-2
@@ -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 {
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
/**
|
||||
* 附件直链的令牌处理。
|
||||
* 附件直链的地址处理。
|
||||
*
|
||||
* 库里存的 Markdown 必须是"干净"的:只记 `/api/v1/.../attachments/<id>/raw`。
|
||||
* 原因有两条——
|
||||
* 1. `<img>` 发不出 Authorization 头,图片只能靠 query 里的 token 鉴权;
|
||||
* 2. token 会过期。要是把它写进正文,几天后整篇文档的图片全变红叉,
|
||||
* 而且团队里每个人拿到的 token 还不一样,同一个字段没法共用。
|
||||
* 库里存的 Markdown 必须是"干净"的:只记 `/api/v1/.../attachments/<id>/raw`,不带任何查询串。
|
||||
* 原因有三条——
|
||||
* 1. `<img>` 发不出 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<string, string>;
|
||||
|
||||
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;
|
||||
|
||||
@@ -145,7 +145,7 @@ export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
||||
* 附件直链:`<img>` 带不了 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`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** 纯文本摘要,用于列表里的一行预览(去掉标记符号,压缩空白)。 */
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface Attachment {
|
||||
size: number;
|
||||
uploader_id: string;
|
||||
created_at: string;
|
||||
/** 后端签发的短期直链(带 exp/sig),正文图片直接用它 */
|
||||
raw_url?: string;
|
||||
can_delete: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Attachment[]>([]);
|
||||
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
|
||||
const resources = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user