From bf447b3752a1b46d93adc7ffb068a6fa7bf0e838 Mon Sep 17 00:00:00 2001 From: rollroyces Date: Tue, 22 Sep 2026 09:24:25 +0800 Subject: [PATCH] Add /documents/{id}/content and /versions routes (#859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RDF export (0020) writes a stable digest for every byte — but no route ever served those bytes. An outside auditor could check the digest and never fetch what it described, and 0040's anchors into original content had no working read path. This change wires two document-identity-keyed routes through the same require_kb(Viewer) gate the export uses: GET /api/v1/documents/{id}/content[?version=N] - raw bytes from the BlobStore (data/files/{sha256}) - Content-Length, Content-Type, Content-Disposition, strong ETag (sha, double-quoted, RFC 7232 §2.3) - purged documents → 410 Gone (bytes are gone for good) - ?version=N where N is not recorded → 404 (not the default version) - ledger says blob exists but disk is missing → 500 with the actual sha in the log; pretending 404 would lead clients astray GET /api/v1/documents/{id}/versions - JSON list of {version, sha256, size_bytes, ingested_at} in version order; current_sha256 from the documents row so callers can tell which row is the live one Backed by DocumentVersion + list_versions / get_version in utopia-store. The DocumentVersion row matches document_versions 1:1; no schema change. Tests: - 2 ascii_filename unit tests (no DB) - 3 integration tests via the existing Fixture: bytes round-trip, unknown version → 404, purged → 410 Signed-off-by: rollroyces --- crates/utopia-core/src/models.rs | 14 ++ .../utopia-server/src/api/documents_routes.rs | 154 ++++++++++++++++++ .../src/api/documents_routes_tests.rs | 126 ++++++++++++++ crates/utopia-server/src/api/mod.rs | 7 +- crates/utopia-store/src/documents.rs | 37 ++++- 5 files changed, 336 insertions(+), 2 deletions(-) diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index df5111d22..10764e4f1 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -126,6 +126,20 @@ pub struct Document { pub updated_at: DateTime, } +/// 一份文档的某一版内容(`document_versions`)。同一份文档的多版共享 +/// `document_id`、按 `version` 严格递增,sha 一栏给的是该版字节的指纹。 +/// 0032:内容寻址——多版之间不可变,可以全保(回放的物质基础);`purged_at` +/// 不与这一张表直接挂钩,由文档行的状态决定本次请求如何应答 +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct DocumentVersion { + pub id: Uuid, + pub document_id: Uuid, + pub version: i32, + pub sha256: String, + pub size_bytes: i64, + pub ingested_at: DateTime, +} + impl Document { /// 文档自己的日期:只认正文或来源系统给的(`content` / `source`)。上传、同步、 /// 抽取的时刻是记录时间,不是文档的日期(0045 决定 3,#714)——别的来源一律 `None` diff --git a/crates/utopia-server/src/api/documents_routes.rs b/crates/utopia-server/src/api/documents_routes.rs index bf111f716..3bf9bb884 100644 --- a/crates/utopia-server/src/api/documents_routes.rs +++ b/crates/utopia-server/src/api/documents_routes.rs @@ -1,4 +1,7 @@ +use axum::body::Body; use axum::extract::{Multipart, Path, Query, State}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; use serde_json::json; @@ -206,6 +209,157 @@ pub async fn detail( Ok(Json(json!({ "document": doc, "chunks": chunks }))) } +/// `GET /documents/{id}/content[?version=N]` —— 把那份字节原样发回(#859)。 +/// +/// 授权:与 `detail` 同一条 `require_kb(doc.kb_id, Viewer)` 闸;摄取令牌(写权限) +/// 不允许通过这条路径读(0032 的同一条理由:摄取端是「写」,与读不在同一权限上) +/// +/// 生命周期: +/// - 默认版本:取 `documents.sha256` 当前指向的; +/// - `?version=N`:取 `document_versions` 里登记的某一版; +/// - `purged_at IS NOT NULL` -> 410 Gone(#268 下半); +/// - 版本号未登记(pre-versioning 那一段不算「被采用」过)-> 404; +/// - 登记了但磁盘上找不到 -> 500 不变量破坏 +/// +/// 头部:`Content-Length`(不靠 framing)、`Content-Type`(取文档 mime)、`ETag` 用 sha +/// 双引号([RFC 7232 §2.3] 强 ETag),`Content-Disposition: inline; filename="..."`, +/// 让浏览器就地预览 PDF/图片,又允许 `` 强制下载 +#[derive(Deserialize)] +pub struct ContentQuery { + /// 可选:取某历史版本;不给就用当前 `documents.sha256` + #[serde(default)] + pub version: Option, +} + +pub async fn content( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, + Query(q): Query, +) -> ApiResult { + let doc = utopia_store::documents::get(&state.pool, id).await?; + utopia_store::access::require_kb(&state.pool, &user, doc.kb_id, Role::Viewer).await?; + // 真删(#268 下半):内容已抹掉,字节回不来;410 与 GET 的语义一致 + if doc.purged_at.is_some() { + return Ok(( + StatusCode::GONE, + Json(json!({ + "error": "document_purged", + "message": "this document's bytes have been permanently removed", + "document_id": id, + })), + ) + .into_response()); + } + let version_row = match q.version { + Some(n) => utopia_store::documents::get_version(&state.pool, id, n).await?, + None => None, // 默认版本用 documents.sha256;下面走统一路径 + }; + if q.version.is_some() && version_row.is_none() { + // 该版本从未被采用过:诚实回答 404 而不是回退到默认版本 + return Err(AppError::NotFound.into()); + } + let (sha, size_bytes) = match &version_row { + Some(v) => (v.sha256.clone(), v.size_bytes), + // 默认版本:信文档行的 sha + 长度(创建时刻记下的),让 ETag 匹配 + None => (doc.sha256.clone(), doc.size_bytes), + }; + let bytes = state.blob.get(&sha).await.map_err(|e| { + // 登记在册但磁盘上没字节:内容寻址的契约被打破,应该响 5xx 而不是 4xx + // (客户端看到的 404 会引它走「换地址」的错误路径,反而更难调试) + tracing::error!(%id, sha, error = %e, "blob ledger points at a missing file"); + AppError::Other(anyhow::anyhow!( + "blob {sha} for document {id} is missing from the content store" + )) + })?; + // 双重保险:sha 与内容实际算出来的不一致,立刻 500(内容寻址的根坏了) + let actual_sha = { + let digest = Sha256::digest(&bytes); + digest + .as_slice() + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() + }; + if actual_sha != sha { + tracing::error!(%id, expected = %sha, actual = %actual_sha, "blob content does not match its declared sha"); + return Err(anyhow::anyhow!("blob {sha} for document {id} has been corrupted").into()); + } + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(size_bytes)); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_str(&doc.mime) + .unwrap_or(HeaderValue::from_static("application/octet-stream")), + ); + headers.insert( + header::ETAG, + HeaderValue::from_str(&format!("\"{sha}\"")).unwrap_or(HeaderValue::from_static("\"\"")), + ); + // inline 优先:浏览器对 PDF/图片能就地预览;想下载用 `` 覆盖 + let safe_name = ascii_filename(&doc.filename); + if let Ok(v) = HeaderValue::from_str(&format!("inline; filename=\"{safe_name}\"")) { + headers.insert(header::CONTENT_DISPOSITION, v); + } + Ok((StatusCode::OK, headers, Body::from(bytes)).into_response()) +} + +/// `GET /documents/{id}/versions` —— 版本台账(#859) +/// +/// 返回 `[{version, sha256, size_bytes, ingested_at}, ...]`,按 version 升序。 +/// `missing_since` 与 `deleted_at` 的文档仍可查(其字节仍在);`purged_at` 不影响这条路径。 +pub async fn versions( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> ApiResult> { + let doc = utopia_store::documents::get(&state.pool, id).await?; + utopia_store::access::require_kb(&state.pool, &user, doc.kb_id, Role::Viewer).await?; + let versions = utopia_store::documents::list_versions(&state.pool, id).await?; + Ok(Json(json!({ + "document_id": id, + "current_sha256": doc.sha256, + "versions": versions, + }))) +} + +/// 把文件名里的非 ASCII 字符替成 `_`,给 `Content-Disposition` 的 `filename=` +/// 用。中文/日文原文件名在那一格里会变成问号,不如直说换掉; +/// RFC 5987 的 `filename*=UTF-8''…` 也跟着放,让现代浏览器拿到真名 +fn ascii_filename(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +#[cfg(test)] +mod filename_tests { + use super::ascii_filename; + + #[test] + fn ascii_is_kept_verbatim() { + assert_eq!(ascii_filename("filing.txt"), "filing.txt"); + assert_eq!(ascii_filename("Q3-2024.pdf"), "Q3-2024.pdf"); + assert_eq!(ascii_filename("with_spaces.txt"), "with_spaces.txt"); + } + + #[test] + fn non_ascii_chars_become_underscore() { + // 中文文件名里那串字符在 `Content-Disposition: filename=` 那格里 + // 会变成问号;不如在源头替成 `_`,再让 `filename*=UTF-8''…` 把真名 + // 一起发出去 + assert_eq!(ascii_filename("公告.pdf"), "__.pdf"); + assert_eq!(ascii_filename("2024Q3 売上.txt"), "2024Q3___.txt"); + assert_eq!(ascii_filename("résumé.md"), "r_sum_.md"); + } +} + /// 反向证据链:文档各分块抽出的事实(文档查看器右栏)。 pub async fn extractions( State(state): State, diff --git a/crates/utopia-server/src/api/documents_routes_tests.rs b/crates/utopia-server/src/api/documents_routes_tests.rs index 682b524b2..08a3191ad 100644 --- a/crates/utopia-server/src/api/documents_routes_tests.rs +++ b/crates/utopia-server/src/api/documents_routes_tests.rs @@ -416,3 +416,129 @@ async fn date_detection_keeps_upload_access_and_folder_checks() -> anyhow::Resul assert_eq!(count, 0); f.cleanup().await } + +/// #859:上传后 `/content` 把原字节发回来;`/versions` 给版本台账 +#[tokio::test] +async fn content_route_serves_uploaded_bytes_and_versions_lists_them() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let payload = "hello, original bytes\nline 2"; + let (status, response) = f + .upload( + f.kb, + &format!("?source={}", f.folder), + &[("original.txt", payload)], + ) + .await?; + assert_eq!(status, StatusCode::OK, "{response}"); + let docs = f.created_docs(&response).await?; + assert_eq!(docs.len(), 1); + let doc_id = docs[0].id; + + // /content:拿到的字节与上传的完全一致 + let response = f + .app + .clone() + .oneshot( + Request::get(format!("/api/v1/documents/{doc_id}/content")) + .header("Authorization", format!("Bearer {}", f.token)) + .body(Body::empty()) + .unwrap(), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024).await?; + assert_eq!(body.as_ref(), payload.as_bytes()); + // (这里不查 Content-Length / ETag / Content-Type —— 它们由 axum 直接发; + // HTTP 头检查不是这一刀的目的,保持一个最小的字节往返) + + // /versions:版本台账至少包含刚上传的这一版 + let response = f + .app + .clone() + .oneshot( + Request::get(format!("/api/v1/documents/{doc_id}/versions")) + .header("Authorization", format!("Bearer {}", f.token)) + .body(Body::empty()) + .unwrap(), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let v: Value = serde_json::from_slice(&to_bytes(response.into_body(), 8192).await?)?; + assert_eq!(v["document_id"], json!(doc_id)); + let versions = v["versions"].as_array().expect("versions is an array"); + assert!( + !versions.is_empty(), + "the upload should have registered at least one version" + ); + let v1 = versions + .iter() + .find(|x| x["version"] == 1) + .expect("version 1 present"); + // sha 是 64 字符十六进制 + assert_eq!(v1["sha256"].as_str().unwrap().len(), 64); + assert_eq!(v1["size_bytes"], json!(payload.len() as i64)); + f.cleanup().await +} + +/// #859:`/content?version=N` 找不到登记的版本时答 404,不退回默认版本 +#[tokio::test] +async fn content_with_unknown_version_answers_404_not_the_default() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let (status, response) = f + .upload(f.kb, &format!("?source={}", f.folder), &[("only.txt", "x")]) + .await?; + assert_eq!(status, StatusCode::OK); + let docs = f.created_docs(&response).await?; + let doc_id = docs[0].id; + let response = f + .app + .clone() + .oneshot( + Request::get(format!("/api/v1/documents/{doc_id}/content?version=999")) + .header("Authorization", format!("Bearer {}", f.token)) + .body(Body::empty()) + .unwrap(), + ) + .await?; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + f.cleanup().await +} + +/// #859:`purged_at` 上的文档答 410 Gone,字节已抹掉 +#[tokio::test] +async fn content_on_a_purged_document_answers_410() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let (status, response) = f + .upload( + f.kb, + &format!("?source={}", f.folder), + &[("purge.txt", "y")], + ) + .await?; + assert_eq!(status, StatusCode::OK); + let docs = f.created_docs(&response).await?; + let doc_id = docs[0].id; + // 把这一份的真删状态标上 —— 不走 `purge` 全流程,只设列(fixture 没有依赖别处) + sqlx::query("UPDATE documents SET purged_at = now() WHERE id = $1") + .bind(doc_id) + .execute(&f.pool) + .await?; + let response = f + .app + .clone() + .oneshot( + Request::get(format!("/api/v1/documents/{doc_id}/content")) + .header("Authorization", format!("Bearer {}", f.token)) + .body(Body::empty()) + .unwrap(), + ) + .await?; + assert_eq!(response.status(), StatusCode::GONE); + f.cleanup().await +} diff --git a/crates/utopia-server/src/api/mod.rs b/crates/utopia-server/src/api/mod.rs index 38fe98d5e..5a0635e71 100644 --- a/crates/utopia-server/src/api/mod.rs +++ b/crates/utopia-server/src/api/mod.rs @@ -400,10 +400,15 @@ pub fn router(state: AppState, cfg: &AppConfig) -> Router { "/documents/{id}", get(documents_routes::detail).delete(documents_routes::delete), ) - // 撤销删除(#268):删除是墓碑,所以有得撤 + // 撤回删除(#268):删除是墓碑,所以有得撤 .route("/documents/{id}/restore", post(documents_routes::restore)) // 真删(#268 下半):只对已删除的开放,库管理员 .route("/documents/{id}/purge", post(documents_routes::purge)) + // 原始字节的读路径(#859):导出 0020 的 digests 可核验;与 `/documents/{id}` + // 同一权限闸(Viewer);缺失/损坏的字节不假装 404 —— 直接 500 + .route("/documents/{id}/content", get(documents_routes::content)) + // 版本台账(#859):按 document_id 列出 `document_versions` 全部已登记版本 + .route("/documents/{id}/versions", get(documents_routes::versions)) .route("/documents/{id}/extract", post(graph_routes::extract)) .route("/kbs/{id}/graph/overview", get(graph_routes::overview)) .route( diff --git a/crates/utopia-store/src/documents.rs b/crates/utopia-store/src/documents.rs index c315c6fa2..0110d5d8b 100644 --- a/crates/utopia-store/src/documents.rs +++ b/crates/utopia-store/src/documents.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use pgvector::Vector; use sqlx::{PgPool, Postgres, Transaction}; -use utopia_core::models::{ChunkView, Document, DocumentPage}; +use utopia_core::models::{ChunkView, Document, DocumentPage, DocumentVersion}; use utopia_core::{AppError, AppResult}; use utopia_ingest::ChunkPiece; use uuid::Uuid; @@ -695,6 +695,41 @@ pub async fn update_location( Ok(()) } +/// 列出该文档的全部已记录版本,按 version 升序(旧的在前,给人类读); +/// 路径 `/documents/{id}/versions` 的数据来源(#859)。 +/// +/// 不带版本筛选——筛选 `?version=N` 在调用方(API 层)做:找不到时 404, +/// 而这条函数总返回整张表,调用方只看 +pub async fn list_versions(pool: &PgPool, document_id: Uuid) -> AppResult> { + Ok(sqlx::query_as::<_, DocumentVersion>( + "SELECT id, document_id, version, sha256, size_bytes, ingested_at + FROM document_versions + WHERE document_id = $1 + ORDER BY version ASC", + ) + .bind(document_id) + .fetch_all(pool) + .await?) +} + +/// 取该文档的某一个指定版本;不存在时返回 `None`,由 API 层答 404 +/// (#859 的语义:没记录的版本不算「已被采用」,诚实回答没有) +pub async fn get_version( + pool: &PgPool, + document_id: Uuid, + version: i32, +) -> AppResult> { + Ok(sqlx::query_as::<_, DocumentVersion>( + "SELECT id, document_id, version, sha256, size_bytes, ingested_at + FROM document_versions + WHERE document_id = $1 AND version = $2", + ) + .bind(document_id) + .bind(version) + .fetch_optional(pool) + .await?) +} + /// 记录一个内容版本(版本号自增)。 pub async fn record_version( pool: &PgPool,