From 3fe35442233adc1b67476b7de8fe1370a1aa4b73 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sat, 18 Jul 2026 16:55:45 -0400 Subject: [PATCH] Add cloud documents, folders, and shared items to desktop API --- server/src/desktop.rs | 273 ++++++++++++++++++++++++++++++++++++++++++ server/src/main.rs | 4 + 2 files changed, 277 insertions(+) diff --git a/server/src/desktop.rs b/server/src/desktop.rs index 5e20804..86d8406 100644 --- a/server/src/desktop.rs +++ b/server/src/desktop.rs @@ -700,3 +700,276 @@ pub async fn pull_space( files, })) } + +#[derive(Serialize)] +pub struct CloudFolder { + pub id: String, + pub name: String, + pub parent_id: Option, +} + +pub async fn list_folders( + State(state): State, + headers: HeaderMap, +) -> Result>, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + + let rows = sqlx::query_as::<_, (String, String, Option)>( + "SELECT id, name, parent_id FROM folders WHERE owner_id = ? ORDER BY name ASC", + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json( + rows.into_iter() + .map(|(id, name, parent_id)| CloudFolder { + id, + name, + parent_id, + }) + .collect(), + )) +} + +#[derive(Serialize)] +pub struct CloudDocument { + pub id: String, + pub title: String, + pub folder_id: Option, + pub role: String, + pub updated_at: String, +} + +#[derive(Deserialize)] +pub struct FolderQuery { + pub folder_id: Option, +} + +pub async fn list_documents( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result>, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + + let rows = match &query.folder_id { + Some(folder_id) => sqlx::query_as::<_, (String, String, Option, String)>( + "SELECT id, title, folder_id, updated_at FROM documents \ + WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC", + ) + .bind(&user_id) + .bind(folder_id) + .fetch_all(&state.db) + .await, + None => sqlx::query_as::<_, (String, String, Option, String)>( + "SELECT id, title, folder_id, updated_at FROM documents \ + WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC", + ) + .bind(&user_id) + .fetch_all(&state.db) + .await, + } + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json( + rows.into_iter() + .map(|(id, title, folder_id, updated_at)| CloudDocument { + id, + title, + folder_id, + role: "owner".to_string(), + updated_at, + }) + .collect(), + )) +} + +#[derive(Serialize)] +pub struct SharedItems { + pub documents: Vec, + pub spaces: Vec, +} + +pub async fn list_shared( + State(state): State, + headers: HeaderMap, +) -> Result, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + + let documents = sqlx::query_as::<_, (String, String, Option, String, String)>( + "SELECT d.id, d.title, d.folder_id, d.updated_at, c.role FROM documents d \ + INNER JOIN collaborators c ON c.document_id = d.id AND c.user_id = ? \ + ORDER BY d.updated_at DESC", + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let spaces = sqlx::query_as::<_, (String, String, String, String, String)>( + "SELECT s.id, s.name, s.entrypoint, s.updated_at, c.role FROM spaces s \ + INNER JOIN space_collaborators c ON c.space_id = s.id AND c.user_id = ? \ + ORDER BY s.updated_at DESC", + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(SharedItems { + documents: documents + .into_iter() + .map(|(id, title, folder_id, updated_at, role)| CloudDocument { + id, + title, + folder_id, + role, + updated_at, + }) + .collect(), + spaces: spaces + .into_iter() + .map(|(id, name, entrypoint, updated_at, role)| SpaceSummary { + id, + name, + entrypoint, + role, + updated_at, + }) + .collect(), + })) +} + +async fn document_role( + state: &AppState, + document_id: &str, + user_id: &str, +) -> Result<(String, String, String), (StatusCode, String)> { + let row = sqlx::query_as::<_, (String, String, Option>, Option)>( + "SELECT owner_id, title, content, public_role FROM documents WHERE id = ?", + ) + .bind(document_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Document not found".to_string()))?; + + let (owner_id, title, content, public_role) = row; + + let mut role = if owner_id == user_id { + "owner".to_string() + } else { + "none".to_string() + }; + + if role == "none" { + if let Ok(Some((collaborator_role,))) = sqlx::query_as::<_, (String,)>( + "SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?", + ) + .bind(document_id) + .bind(user_id) + .fetch_optional(&state.db) + .await + { + role = collaborator_role; + } + } + + if role == "none" { + if let Some(public) = public_role { + if public == "viewer" || public == "editor" { + role = public; + } + } + } + + if role == "none" { + return Err((StatusCode::FORBIDDEN, "No access to this document".to_string())); + } + + let text = decode_text_blob(&content.unwrap_or_default()); + Ok((role, title, text)) +} + +#[derive(Serialize)] +pub struct DocumentContent { + pub id: String, + pub title: String, + pub role: String, + pub hash: String, + pub content: String, +} + +pub async fn pull_document( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + let (role, title, content) = document_role(&state, &id, &user_id).await?; + + Ok(Json(DocumentContent { + id, + title, + role, + hash: content_hash(content.as_bytes()), + content, + })) +} + +#[derive(Deserialize)] +pub struct PushDocumentRequest { + pub content: String, + pub base_hash: Option, +} + +pub async fn push_document( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(payload): Json, +) -> Result { + let user_id = authenticate(&state, &headers).await?; + let (role, _, current) = document_role(&state, &id, &user_id).await?; + + if role != "owner" && role != "editor" { + return Err((StatusCode::FORBIDDEN, "Read-only access".to_string())); + } + + let server_hash = content_hash(current.as_bytes()); + let incoming_hash = content_hash(payload.content.as_bytes()); + + let safe = match &payload.base_hash { + Some(base) => base == &server_hash, + None => false, + }; + + if !safe && server_hash != incoming_hash { + return Ok(PushOutcome::Conflict(Json(ConflictResponse { + conflict: true, + path: id, + server_hash, + base_hash: payload.base_hash, + encoding: "utf8".to_string(), + server_content: current, + }))); + } + + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + sqlx::query("UPDATE documents SET content = ?, updated_at = ? WHERE id = ?") + .bind(encode_text_blob(&payload.content)) + .bind(&now) + .bind(&id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(PushOutcome::Applied(Json(PushFileResponse { + path: id, + hash: incoming_hash, + updated_at: now, + }))) +} diff --git a/server/src/main.rs b/server/src/main.rs index 9d246b7..6a10d24 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -148,6 +148,10 @@ async fn main() { .route("/spaces", get(desktop::list_spaces).post(desktop::create_space)) .route("/spaces/{id}", get(desktop::pull_space).delete(desktop::delete_space)) .route("/spaces/{id}/manifest", get(desktop::get_manifest)) + .route("/folders", get(desktop::list_folders)) + .route("/documents", get(desktop::list_documents)) + .route("/documents/{id}", get(desktop::pull_document).put(desktop::push_document)) + .route("/shared", get(desktop::list_shared)) .route("/spaces/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file)); let v1_routes = Router::new()