Add desktop sync API

This commit is contained in:
2026-07-18 15:00:31 -04:00
parent 690d504535
commit 630b668760
5 changed files with 754 additions and 1 deletions
+23
View File
@@ -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. - **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. - **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. - **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 ## 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%) #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 <token>` 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 ## Self-Hosting
TypstDrive is completely self-hostable. A Docker image packages both the Rust backend and the SvelteKit frontend into a single container. TypstDrive is completely self-hostable. A Docker image packages both the Rust backend and the SvelteKit frontend into a single container.
+9
View File
@@ -159,6 +159,14 @@ pub async fn init_schema(pool: &AnyPool) {
data BYTEA NOT NULL, data BYTEA NOT NULL,
UNIQUE(version_id, path) 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 { 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 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 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 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 { for stmt in &migrations {
sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default()); sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default());
+9
View File
@@ -164,6 +164,14 @@ pub async fn init_schema(pool: &AnyPool) {
data BLOB NOT NULL, data BLOB NOT NULL,
UNIQUE(version_id, path) 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 { for stmt in &statements {
@@ -177,6 +185,7 @@ pub async fn init_schema(pool: &AnyPool) {
let migrations = [ let migrations = [
"ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0", "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 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 { for stmt in &migrations {
let _ = sqlx::query(stmt).execute(pool).await; let _ = sqlx::query(stmt).execute(pool).await;
+702
View File
@@ -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<String> {
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<String, (StatusCode, String)> {
let token = bearer_token(headers).ok_or((
StatusCode::UNAUTHORIZED,
"Missing Authorization header. Use: Authorization: Bearer <device-token>".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<Space, (StatusCode, String)> {
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<String>,
}
#[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<AppState>,
Json(payload): Json<DeviceLoginRequest>,
) -> Result<Json<DeviceLoginResponse>, (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<AppState>,
headers: HeaderMap,
) -> Result<Json<DeviceUser>, (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<AppState>,
headers: HeaderMap,
) -> Result<StatusCode, (StatusCode, String)> {
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<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<SpaceSummary>>, (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<SpaceSummary> = 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<String>,
}
pub async fn create_space(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateSpaceBody>,
) -> Result<Json<SpaceSummary>, (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<AppState>,
headers: HeaderMap,
Path(space_id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
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<ManifestEntry>,
}
async fn plain_contents(
state: &AppState,
space_id: &str,
) -> Result<Vec<(String, String, Vec<u8>, String)>, (StatusCode, String)> {
let rows = sqlx::query_as::<_, (String, String, Option<Vec<u8>>, Option<String>)>(
"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<AppState>,
headers: HeaderMap,
Path(space_id): Path<String>,
) -> Result<Json<SpaceManifest>, (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<u8>) -> (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<AppState>,
headers: HeaderMap,
Path(space_id): Path<String>,
Query(query): Query<PathQuery>,
) -> Result<Json<FileContent>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
owned_space(&state, &space_id, &user_id).await?;
let row = sqlx::query_as::<_, (String, Option<Vec<u8>>)>(
"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<String>,
pub base_hash: Option<String>,
}
#[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<String>,
pub encoding: String,
pub server_content: String,
}
pub enum PushOutcome {
Applied(Json<PushFileResponse>),
Conflict(Json<ConflictResponse>),
}
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<AppState>,
headers: HeaderMap,
Path(space_id): Path<String>,
Json(payload): Json<PushFileRequest>,
) -> Result<PushOutcome, (StatusCode, String)> {
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<Vec<u8>>)>(
"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<AppState>,
headers: HeaderMap,
Path(space_id): Path<String>,
Query(query): Query<PathQuery>,
) -> Result<StatusCode, (StatusCode, String)> {
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<BundleFile>,
}
pub async fn pull_space(
State(state): State<AppState>,
headers: HeaderMap,
Path(space_id): Path<String>,
) -> Result<Json<SpaceBundle>, (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,
}))
}
+11 -1
View File
@@ -17,6 +17,7 @@ mod api_keys;
mod auth; mod auth;
mod compiler; mod compiler;
mod db; mod db;
mod desktop;
mod docs; mod docs;
mod folders; mod folders;
mod files; mod files;
@@ -140,6 +141,15 @@ async fn main() {
.route("/packages/publish", post(packages::publish_package)) .route("/packages/publish", post(packages::publish_package))
.route("/packages/{name}", get(packages::list_versions).delete(packages::delete_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() let v1_routes = Router::new()
.route("/render", post(public_api::render_handler)); .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 static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../build".to_string());
let app = Router::new() 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("/v1", v1_routes.layer(TraceLayer::new_for_http()))
.nest("/yjs", yjs_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)))) .fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{}/index.html", static_dir))))