Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ pub struct Document {
pub updated_at: DateTime<Utc>,
}

/// 一份文档的某一版内容(`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<Utc>,
}

impl Document {
/// 文档自己的日期:只认正文或来源系统给的(`content` / `source`)。上传、同步、
/// 抽取的时刻是记录时间,不是文档的日期(0045 决定 3,#714)——别的来源一律 `None`
Expand Down
154 changes: 154 additions & 0 deletions crates/utopia-server/src/api/documents_routes.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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/图片,又允许 `<a download>` 强制下载
#[derive(Deserialize)]
pub struct ContentQuery {
/// 可选:取某历史版本;不给就用当前 `documents.sha256`
#[serde(default)]
pub version: Option<i32>,
}

pub async fn content(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
Query(q): Query<ContentQuery>,
) -> ApiResult<Response> {
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::<String>()
};
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/图片能就地预览;想下载用 `<a download>` 覆盖
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<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
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<AppState>,
Expand Down
126 changes: 126 additions & 0 deletions crates/utopia-server/src/api/documents_routes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
7 changes: 6 additions & 1 deletion crates/utopia-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
37 changes: 36 additions & 1 deletion crates/utopia-store/src/documents.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Vec<DocumentVersion>> {
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<Option<DocumentVersion>> {
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,
Expand Down
Loading