From 630b668760bbfb0dd96bdf599ff8e4460b413640 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sat, 18 Jul 2026 15:00:31 -0400 Subject: [PATCH] Add desktop sync API --- README.md | 23 ++ server/src/db/postgres.rs | 9 + server/src/db/sqlite.rs | 9 + server/src/desktop.rs | 702 ++++++++++++++++++++++++++++++++++++++ server/src/main.rs | 12 +- 5 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 server/src/desktop.rs diff --git a/README.md b/README.md index 9527f03..8298a03 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ TypstDrive is a collaborative web editor for Typst. With built-in dark mode, mul - **Admin System**: First-run setup wizard creates an admin account. Admins can manage all users, create new accounts with temporary passwords, toggle admin privileges, and delete accounts from the Settings panel. - **Presentation Mode**: Turn your documents into instant slideshows with built-in slide controls and a live drawing/annotation tool overlay. - **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents. +- **Desktop Sync API**: A dedicated API under `/api/desktop` lets [Typst Desktop](../typst-desktop) keep local projects in sync with your Spaces, with device-token authentication and hash-based conflict detection. ## Fonts & Images @@ -71,6 +72,28 @@ You can also reference remote images directly by their `http://` or `https://` U #image("https://example.com/logo.png", width: 50%) ``` +## Desktop Sync API + +The desktop app authenticates with a **device token** rather than a session cookie. Sign in once with `POST /api/desktop/auth/login`, then send the returned token as `Authorization: Bearer ` on every request. Tokens are stored hashed and can be revoked from the app by signing out. + +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `/api/desktop/auth/login` | Exchange email and password for a device token. | +| `POST` | `/api/desktop/auth/logout` | Revoke the current device token. | +| `GET` | `/api/desktop/auth/me` | Account the token belongs to. | +| `GET` | `/api/desktop/spaces` | Spaces the account owns or collaborates on. | +| `POST` | `/api/desktop/spaces` | Create a Space. | +| `GET` | `/api/desktop/spaces/{id}` | Full Space contents in one response. | +| `DELETE` | `/api/desktop/spaces/{id}` | Delete a Space. | +| `GET` | `/api/desktop/spaces/{id}/manifest` | Every file with its content hash, for change detection. | +| `GET` | `/api/desktop/spaces/{id}/file?path=` | Read one file. | +| `PUT` | `/api/desktop/spaces/{id}/file` | Write one file. | +| `DELETE` | `/api/desktop/spaces/{id}/file?path=` | Delete one file. | + +### Conflict Detection + +A write sends the `base_hash` the client last saw. If the file on the server no longer matches that hash, the write is rejected with `409` and a body containing the server's current content, so the client can merge instead of overwriting. Text files are stored in the same Yjs format the web editor uses, so a desktop push and a browser edit stay compatible. + ## Self-Hosting TypstDrive is completely self-hostable. A Docker image packages both the Rust backend and the SvelteKit frontend into a single container. diff --git a/server/src/db/postgres.rs b/server/src/db/postgres.rs index 5e6c028..ecfb871 100644 --- a/server/src/db/postgres.rs +++ b/server/src/db/postgres.rs @@ -159,6 +159,14 @@ pub async fn init_schema(pool: &AnyPool) { data BYTEA NOT NULL, UNIQUE(version_id, path) )", + "CREATE TABLE IF NOT EXISTS device_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_used_at TEXT + )", ]; for stmt in &statements { @@ -173,6 +181,7 @@ pub async fn init_schema(pool: &AnyPool) { "ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT", "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE", "ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')", + "ALTER TABLE space_files ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')", ]; for stmt in &migrations { sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default()); diff --git a/server/src/db/sqlite.rs b/server/src/db/sqlite.rs index 55d760c..993eb8a 100644 --- a/server/src/db/sqlite.rs +++ b/server/src/db/sqlite.rs @@ -164,6 +164,14 @@ pub async fn init_schema(pool: &AnyPool) { data BLOB NOT NULL, UNIQUE(version_id, path) )", + "CREATE TABLE IF NOT EXISTS device_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_used_at TEXT + )", ]; for stmt in &statements { @@ -177,6 +185,7 @@ pub async fn init_schema(pool: &AnyPool) { let migrations = [ "ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0", "ALTER TABLE users ADD COLUMN created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))", + "ALTER TABLE space_files ADD COLUMN updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))", ]; for stmt in &migrations { let _ = sqlx::query(stmt).execute(pool).await; diff --git a/server/src/desktop.rs b/server/src/desktop.rs new file mode 100644 index 0000000..5e20804 --- /dev/null +++ b/server/src/desktop.rs @@ -0,0 +1,702 @@ +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + Json, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use argon2::{ + password_hash::{PasswordHash, PasswordVerifier}, + Argon2, +}; + +use crate::{ + models::{Space, User}, + spaces::{decode_text_blob, encode_text_blob}, + AppState, +}; + +const TEXT_EXTENSIONS: [&str; 10] = [ + ".typ", ".toml", ".bib", ".csl", ".yml", ".yaml", ".json", ".md", ".txt", ".csv", +]; + +fn is_text_path(path: &str) -> bool { + let lower = path.to_lowercase(); + TEXT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) +} + +fn content_hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn hash_token(token: &str) -> String { + format!("{:x}", Sha256::digest(token.as_bytes())) +} + +fn generate_token() -> String { + format!( + "tdd_{}{}", + Uuid::new_v4().to_string().replace("-", ""), + Uuid::new_v4().to_string().replace("-", "") + ) +} + +fn bearer_token(headers: &HeaderMap) -> Option { + headers + .get("Authorization") + .and_then(|value| value.to_str().ok()) + .filter(|value| value.starts_with("Bearer ")) + .map(|value| value[7..].to_string()) +} + +pub async fn authenticate( + state: &AppState, + headers: &HeaderMap, +) -> Result { + let token = bearer_token(headers).ok_or(( + StatusCode::UNAUTHORIZED, + "Missing Authorization header. Use: Authorization: Bearer ".to_string(), + ))?; + + let row = sqlx::query_as::<_, (String, String)>( + "SELECT id, user_id FROM device_tokens WHERE token_hash = ?", + ) + .bind(hash_token(&token)) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let (token_id, user_id) = + row.ok_or((StatusCode::UNAUTHORIZED, "Invalid device token".to_string()))?; + + let _ = sqlx::query("UPDATE device_tokens SET last_used_at = ? WHERE id = ?") + .bind(chrono::Utc::now().to_rfc3339()) + .bind(&token_id) + .execute(&state.db) + .await; + + Ok(user_id) +} + +async fn owned_space( + state: &AppState, + space_id: &str, + user_id: &str, +) -> Result { + let space = sqlx::query_as::<_, Space>( + "SELECT s.id, s.owner_id, s.folder_id, s.name, s.entrypoint, s.thumbnail_svg, \ + s.public_role, s.created_at, s.updated_at FROM spaces s \ + WHERE s.id = ? AND (s.owner_id = ? OR EXISTS ( \ + SELECT 1 FROM space_collaborators c \ + WHERE c.space_id = s.id AND c.user_id = ? AND c.role = 'editor'))", + ) + .bind(space_id) + .bind(user_id) + .bind(user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + space.ok_or(( + StatusCode::NOT_FOUND, + "Space not found or not writable".to_string(), + )) +} + +#[derive(Deserialize)] +pub struct DeviceLoginRequest { + pub email: String, + pub password: String, + pub device_name: Option, +} + +#[derive(Serialize)] +pub struct DeviceLoginResponse { + pub token: String, + pub user_id: String, + pub username: String, + pub email: String, +} + +pub async fn login( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let user = sqlx::query_as::<_, User>( + "SELECT id, username, email, password_hash, is_admin FROM users WHERE email = ?", + ) + .bind(&payload.email) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or(( + StatusCode::UNAUTHORIZED, + "Invalid email or password".to_string(), + ))?; + + let parsed_hash = PasswordHash::new(&user.password_hash) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if Argon2::default() + .verify_password(payload.password.as_bytes(), &parsed_hash) + .is_err() + { + return Err(( + StatusCode::UNAUTHORIZED, + "Invalid email or password".to_string(), + )); + } + + let token = generate_token(); + let device_name = payload + .device_name + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "Typst Desktop".to_string()); + + sqlx::query( + "INSERT INTO device_tokens (id, user_id, name, token_hash, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(&user.id) + .bind(&device_name) + .bind(hash_token(&token)) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(DeviceLoginResponse { + token, + user_id: user.id, + username: user.username, + email: user.email, + })) +} + +#[derive(Serialize)] +pub struct DeviceUser { + pub user_id: String, + pub username: String, + pub email: String, +} + +pub async fn me( + State(state): State, + headers: HeaderMap, +) -> Result, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + + let user = sqlx::query_as::<_, User>( + "SELECT id, username, email, password_hash, is_admin FROM users WHERE id = ?", + ) + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + + Ok(Json(DeviceUser { + user_id: user.id, + username: user.username, + email: user.email, + })) +} + +pub async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result { + let token = bearer_token(&headers).ok_or(( + StatusCode::UNAUTHORIZED, + "Missing Authorization header".to_string(), + ))?; + + sqlx::query("DELETE FROM device_tokens WHERE token_hash = ?") + .bind(hash_token(&token)) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Serialize)] +pub struct SpaceSummary { + pub id: String, + pub name: String, + pub entrypoint: String, + pub role: String, + pub updated_at: String, +} + +pub async fn list_spaces( + State(state): State, + headers: HeaderMap, +) -> Result>, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + + let owned = sqlx::query_as::<_, (String, String, String, String)>( + "SELECT id, name, entrypoint, updated_at FROM spaces WHERE owner_id = ? ORDER BY updated_at DESC", + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let shared = 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()))?; + + let mut spaces: Vec = owned + .into_iter() + .map(|(id, name, entrypoint, updated_at)| SpaceSummary { + id, + name, + entrypoint, + role: "owner".to_string(), + updated_at, + }) + .collect(); + + spaces.extend( + shared + .into_iter() + .map(|(id, name, entrypoint, updated_at, role)| SpaceSummary { + id, + name, + entrypoint, + role, + updated_at, + }), + ); + + Ok(Json(spaces)) +} + +#[derive(Deserialize)] +pub struct CreateSpaceBody { + pub name: String, + pub entrypoint: Option, +} + +pub async fn create_space( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + + if payload.name.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string())); + } + + let space_id = Uuid::new_v4().to_string(); + let entrypoint = payload + .entrypoint + .unwrap_or_else(|| "main.typ".to_string()); + + let space = sqlx::query_as::<_, Space>( + "INSERT INTO spaces (id, owner_id, name, entrypoint) VALUES (?, ?, ?, ?) \ + RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at", + ) + .bind(&space_id) + .bind(&user_id) + .bind(payload.name.trim()) + .bind(&entrypoint) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(SpaceSummary { + id: space.id, + name: space.name, + entrypoint: space.entrypoint, + role: "owner".to_string(), + updated_at: space.updated_at, + })) +} + +pub async fn delete_space( + State(state): State, + headers: HeaderMap, + Path(space_id): Path, +) -> Result { + let user_id = authenticate(&state, &headers).await?; + + let _ = sqlx::query("DELETE FROM space_files WHERE space_id = ?") + .bind(&space_id) + .execute(&state.db) + .await; + + let result = sqlx::query("DELETE FROM spaces WHERE id = ? AND owner_id = ?") + .bind(&space_id) + .bind(&user_id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Space not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Serialize)] +pub struct ManifestEntry { + pub path: String, + pub kind: String, + pub hash: String, + pub size: usize, + pub updated_at: String, +} + +#[derive(Serialize)] +pub struct SpaceManifest { + pub space_id: String, + pub name: String, + pub entrypoint: String, + pub updated_at: String, + pub files: Vec, +} + +async fn plain_contents( + state: &AppState, + space_id: &str, +) -> Result, String)>, (StatusCode, String)> { + let rows = sqlx::query_as::<_, (String, String, Option>, Option)>( + "SELECT path, kind, content, updated_at FROM space_files WHERE space_id = ? ORDER BY path ASC", + ) + .bind(space_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(rows + .into_iter() + .map(|(path, kind, content, updated_at)| { + let raw = content.unwrap_or_default(); + let plain = if kind == "binary" { + raw + } else { + decode_text_blob(&raw).into_bytes() + }; + (path, kind, plain, updated_at.unwrap_or_default()) + }) + .collect()) +} + +pub async fn get_manifest( + State(state): State, + headers: HeaderMap, + Path(space_id): Path, +) -> Result, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + let space = owned_space(&state, &space_id, &user_id).await?; + + let files = plain_contents(&state, &space_id) + .await? + .into_iter() + .map(|(path, kind, plain, updated_at)| ManifestEntry { + path, + kind, + hash: content_hash(&plain), + size: plain.len(), + updated_at, + }) + .collect(); + + Ok(Json(SpaceManifest { + space_id: space.id, + name: space.name, + entrypoint: space.entrypoint, + updated_at: space.updated_at, + files, + })) +} + +#[derive(Deserialize)] +pub struct PathQuery { + pub path: String, +} + +#[derive(Serialize)] +pub struct FileContent { + pub path: String, + pub kind: String, + pub hash: String, + pub encoding: String, + pub content: String, +} + +fn encode_for_transport(kind: &str, plain: Vec) -> (String, String) { + if kind == "binary" { + ("base64".to_string(), BASE64.encode(&plain)) + } else { + ( + "utf8".to_string(), + String::from_utf8_lossy(&plain).to_string(), + ) + } +} + +pub async fn pull_file( + State(state): State, + headers: HeaderMap, + Path(space_id): Path, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + owned_space(&state, &space_id, &user_id).await?; + + let row = sqlx::query_as::<_, (String, Option>)>( + "SELECT kind, content FROM space_files WHERE space_id = ? AND path = ?", + ) + .bind(&space_id) + .bind(&query.path) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?; + + let (kind, content) = row; + let raw = content.unwrap_or_default(); + let plain = if kind == "binary" { + raw + } else { + decode_text_blob(&raw).into_bytes() + }; + + let hash = content_hash(&plain); + let (encoding, content) = encode_for_transport(&kind, plain); + + Ok(Json(FileContent { + path: query.path, + kind, + hash, + encoding, + content, + })) +} + +#[derive(Deserialize)] +pub struct PushFileRequest { + pub path: String, + pub content: String, + pub encoding: Option, + pub base_hash: Option, +} + +#[derive(Serialize)] +pub struct PushFileResponse { + pub path: String, + pub hash: String, + pub updated_at: String, +} + +#[derive(Serialize)] +pub struct ConflictResponse { + pub conflict: bool, + pub path: String, + pub server_hash: String, + pub base_hash: Option, + pub encoding: String, + pub server_content: String, +} + +pub enum PushOutcome { + Applied(Json), + Conflict(Json), +} + +impl axum::response::IntoResponse for PushOutcome { + fn into_response(self) -> axum::response::Response { + match self { + PushOutcome::Applied(body) => (StatusCode::OK, body).into_response(), + PushOutcome::Conflict(body) => (StatusCode::CONFLICT, body).into_response(), + } + } +} + +pub async fn push_file( + State(state): State, + headers: HeaderMap, + Path(space_id): Path, + Json(payload): Json, +) -> Result { + let user_id = authenticate(&state, &headers).await?; + owned_space(&state, &space_id, &user_id).await?; + + let incoming = match payload.encoding.as_deref() { + Some("base64") => BASE64 + .decode(payload.content.as_bytes()) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64: {}", e)))?, + _ => payload.content.clone().into_bytes(), + }; + + let existing = sqlx::query_as::<_, (String, Option>)>( + "SELECT kind, content FROM space_files WHERE space_id = ? AND path = ?", + ) + .bind(&space_id) + .bind(&payload.path) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some((existing_kind, existing_content)) = &existing { + let raw = existing_content.clone().unwrap_or_default(); + let plain = if existing_kind == "binary" { + raw + } else { + decode_text_blob(&raw).into_bytes() + }; + let server_hash = content_hash(&plain); + + let safe = match &payload.base_hash { + Some(base) => base == &server_hash, + None => false, + }; + + if !safe && server_hash != content_hash(&incoming) { + let (encoding, server_content) = encode_for_transport(existing_kind, plain); + return Ok(PushOutcome::Conflict(Json(ConflictResponse { + conflict: true, + path: payload.path, + server_hash, + base_hash: payload.base_hash, + encoding, + server_content, + }))); + } + } + + let kind = if payload.encoding.as_deref() == Some("base64") && !is_text_path(&payload.path) { + "binary" + } else { + "text" + }; + + let stored = if kind == "binary" { + incoming.clone() + } else { + encode_text_blob(&String::from_utf8_lossy(&incoming)) + }; + + let mime_type = if kind == "binary" { + "application/octet-stream" + } else { + "text/plain" + }; + + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + sqlx::query( + "INSERT INTO space_files (id, space_id, path, kind, content, mime_type, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT (space_id, path) DO UPDATE SET content = excluded.content, \ + kind = excluded.kind, mime_type = excluded.mime_type, updated_at = excluded.updated_at", + ) + .bind(Uuid::new_v4().to_string()) + .bind(&space_id) + .bind(&payload.path) + .bind(kind) + .bind(&stored) + .bind(mime_type) + .bind(&now) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let _ = sqlx::query("UPDATE spaces SET updated_at = ? WHERE id = ?") + .bind(&now) + .bind(&space_id) + .execute(&state.db) + .await; + + Ok(PushOutcome::Applied(Json(PushFileResponse { + path: payload.path, + hash: content_hash(&incoming), + updated_at: now, + }))) +} + +pub async fn delete_file( + State(state): State, + headers: HeaderMap, + Path(space_id): Path, + Query(query): Query, +) -> Result { + let user_id = authenticate(&state, &headers).await?; + owned_space(&state, &space_id, &user_id).await?; + + let result = sqlx::query("DELETE FROM space_files WHERE space_id = ? AND path = ?") + .bind(&space_id) + .bind(&query.path) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "File not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Serialize)] +pub struct BundleFile { + pub path: String, + pub kind: String, + pub hash: String, + pub encoding: String, + pub content: String, +} + +#[derive(Serialize)] +pub struct SpaceBundle { + pub space_id: String, + pub name: String, + pub entrypoint: String, + pub files: Vec, +} + +pub async fn pull_space( + State(state): State, + headers: HeaderMap, + Path(space_id): Path, +) -> Result, (StatusCode, String)> { + let user_id = authenticate(&state, &headers).await?; + let space = owned_space(&state, &space_id, &user_id).await?; + + let files = plain_contents(&state, &space_id) + .await? + .into_iter() + .map(|(path, kind, plain, _)| { + let hash = content_hash(&plain); + let (encoding, content) = encode_for_transport(&kind, plain); + BundleFile { + path, + kind, + hash, + encoding, + content, + } + }) + .collect(); + + Ok(Json(SpaceBundle { + space_id: space.id, + name: space.name, + entrypoint: space.entrypoint, + files, + })) +} diff --git a/server/src/main.rs b/server/src/main.rs index 6ecbae0..9d246b7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -17,6 +17,7 @@ mod api_keys; mod auth; mod compiler; mod db; +mod desktop; mod docs; mod folders; mod files; @@ -140,6 +141,15 @@ async fn main() { .route("/packages/publish", post(packages::publish_package)) .route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package)); + let desktop_routes = Router::new() + .route("/auth/login", post(desktop::login)) + .route("/auth/logout", post(desktop::logout)) + .route("/auth/me", get(desktop::me)) + .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("/spaces/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file)); + let v1_routes = Router::new() .route("/render", post(public_api::render_handler)); @@ -149,7 +159,7 @@ async fn main() { let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../build".to_string()); let app = Router::new() - .nest("/api", api_routes.layer(TraceLayer::new_for_http())) + .nest("/api", api_routes.nest("/desktop", desktop_routes).layer(TraceLayer::new_for_http())) .nest("/v1", v1_routes.layer(TraceLayer::new_for_http())) .nest("/yjs", yjs_routes.layer(TraceLayer::new_for_http())) .fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{}/index.html", static_dir))))