Rename Space to Project, add desktop document creation
This commit is contained in:
@@ -1,6 +1,21 @@
|
||||
use sqlx::AnyPool;
|
||||
|
||||
pub async fn init_schema(pool: &AnyPool) {
|
||||
// Rename the legacy "space" tables/columns to the "project" vocabulary on
|
||||
// existing databases. Best-effort: on a fresh database (or one already
|
||||
// migrated) the old names don't exist, so these fail silently and the
|
||||
// CREATE TABLE IF NOT EXISTS statements below take over.
|
||||
let rename_migrations = [
|
||||
"ALTER TABLE IF EXISTS spaces RENAME TO projects",
|
||||
"ALTER TABLE IF EXISTS space_files RENAME TO project_files",
|
||||
"ALTER TABLE IF EXISTS space_collaborators RENAME TO project_collaborators",
|
||||
"ALTER TABLE IF EXISTS project_files RENAME COLUMN space_id TO project_id",
|
||||
"ALTER TABLE IF EXISTS project_collaborators RENAME COLUMN space_id TO project_id",
|
||||
];
|
||||
for stmt in &rename_migrations {
|
||||
let _ = sqlx::query(stmt).execute(pool).await;
|
||||
}
|
||||
|
||||
let statements = [
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -105,7 +120,7 @@ pub async fn init_schema(pool: &AnyPool) {
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY(key_id, minute)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS spaces (
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
folder_id TEXT REFERENCES folders(id),
|
||||
@@ -116,23 +131,23 @@ pub async fn init_schema(pool: &AnyPool) {
|
||||
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS space_files (
|
||||
"CREATE TABLE IF NOT EXISTS project_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'text',
|
||||
content BYTEA,
|
||||
mime_type TEXT NOT NULL DEFAULT 'text/plain',
|
||||
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
UNIQUE(space_id, path)
|
||||
UNIQUE(project_id, path)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS space_collaborators (
|
||||
"CREATE TABLE IF NOT EXISTS project_collaborators (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
UNIQUE(space_id, user_id)
|
||||
UNIQUE(project_id, user_id)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS packages (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -181,7 +196,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')",
|
||||
"ALTER TABLE project_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());
|
||||
|
||||
+23
-8
@@ -6,6 +6,21 @@ pub async fn init_schema(pool: &AnyPool) {
|
||||
.await
|
||||
.expect("Failed to enable SQLite foreign keys");
|
||||
|
||||
// Rename the legacy "space" tables/columns to the "project" vocabulary on
|
||||
// existing databases. Best-effort: on a fresh database (or one already
|
||||
// migrated) the old names don't exist, so these fail silently and the
|
||||
// CREATE TABLE IF NOT EXISTS statements below take over.
|
||||
let rename_migrations = [
|
||||
"ALTER TABLE spaces RENAME TO projects",
|
||||
"ALTER TABLE space_files RENAME TO project_files",
|
||||
"ALTER TABLE space_collaborators RENAME TO project_collaborators",
|
||||
"ALTER TABLE project_files RENAME COLUMN space_id TO project_id",
|
||||
"ALTER TABLE project_collaborators RENAME COLUMN space_id TO project_id",
|
||||
];
|
||||
for stmt in &rename_migrations {
|
||||
let _ = sqlx::query(stmt).execute(pool).await;
|
||||
}
|
||||
|
||||
let statements = [
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -110,7 +125,7 @@ pub async fn init_schema(pool: &AnyPool) {
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY(key_id, minute)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS spaces (
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
folder_id TEXT REFERENCES folders(id),
|
||||
@@ -121,23 +136,23 @@ pub async fn init_schema(pool: &AnyPool) {
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
|
||||
updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS space_files (
|
||||
"CREATE TABLE IF NOT EXISTS project_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'text',
|
||||
content BLOB,
|
||||
mime_type TEXT NOT NULL DEFAULT 'text/plain',
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
|
||||
UNIQUE(space_id, path)
|
||||
UNIQUE(project_id, path)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS space_collaborators (
|
||||
"CREATE TABLE IF NOT EXISTS project_collaborators (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
|
||||
UNIQUE(space_id, user_id)
|
||||
UNIQUE(project_id, user_id)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS packages (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -185,7 +200,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'))",
|
||||
"ALTER TABLE project_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;
|
||||
|
||||
+131
-95
@@ -14,8 +14,8 @@ use argon2::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
models::{Space, User},
|
||||
spaces::{decode_text_blob, encode_text_blob},
|
||||
models::{Project, User},
|
||||
projects::{decode_text_blob, encode_text_blob},
|
||||
AppState,
|
||||
};
|
||||
|
||||
@@ -81,28 +81,28 @@ pub async fn authenticate(
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
async fn owned_space(
|
||||
async fn owned_project(
|
||||
state: &AppState,
|
||||
space_id: &str,
|
||||
project_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'))",
|
||||
) -> Result<Project, (StatusCode, String)> {
|
||||
let project = sqlx::query_as::<_, Project>(
|
||||
"SELECT p.id, p.owner_id, p.folder_id, p.name, p.entrypoint, p.thumbnail_svg, \
|
||||
p.public_role, p.created_at, p.updated_at FROM projects p \
|
||||
WHERE p.id = ? AND (p.owner_id = ? OR EXISTS ( \
|
||||
SELECT 1 FROM project_collaborators c \
|
||||
WHERE c.project_id = p.id AND c.user_id = ? AND c.role = 'editor'))",
|
||||
)
|
||||
.bind(space_id)
|
||||
.bind(project_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((
|
||||
project.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
"Space not found or not writable".to_string(),
|
||||
"Project not found or not writable".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ pub async fn logout(
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SpaceSummary {
|
||||
pub struct ProjectSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub entrypoint: String,
|
||||
@@ -232,14 +232,14 @@ pub struct SpaceSummary {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn list_spaces(
|
||||
pub async fn list_projects(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Vec<SpaceSummary>>, (StatusCode, String)> {
|
||||
) -> Result<Json<Vec<ProjectSummary>>, (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",
|
||||
"SELECT id, name, entrypoint, updated_at FROM projects WHERE owner_id = ? ORDER BY updated_at DESC",
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
@@ -247,18 +247,18 @@ pub async fn list_spaces(
|
||||
.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",
|
||||
"SELECT p.id, p.name, p.entrypoint, p.updated_at, c.role FROM projects p \
|
||||
INNER JOIN project_collaborators c ON c.project_id = p.id AND c.user_id = ? \
|
||||
ORDER BY p.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
|
||||
let mut projects: Vec<ProjectSummary> = owned
|
||||
.into_iter()
|
||||
.map(|(id, name, entrypoint, updated_at)| SpaceSummary {
|
||||
.map(|(id, name, entrypoint, updated_at)| ProjectSummary {
|
||||
id,
|
||||
name,
|
||||
entrypoint,
|
||||
@@ -267,10 +267,10 @@ pub async fn list_spaces(
|
||||
})
|
||||
.collect();
|
||||
|
||||
spaces.extend(
|
||||
projects.extend(
|
||||
shared
|
||||
.into_iter()
|
||||
.map(|(id, name, entrypoint, updated_at, role)| SpaceSummary {
|
||||
.map(|(id, name, entrypoint, updated_at, role)| ProjectSummary {
|
||||
id,
|
||||
name,
|
||||
entrypoint,
|
||||
@@ -279,36 +279,36 @@ pub async fn list_spaces(
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(Json(spaces))
|
||||
Ok(Json(projects))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateSpaceBody {
|
||||
pub struct CreateProjectBody {
|
||||
pub name: String,
|
||||
pub entrypoint: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_space(
|
||||
pub async fn create_project(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateSpaceBody>,
|
||||
) -> Result<Json<SpaceSummary>, (StatusCode, String)> {
|
||||
Json(payload): Json<CreateProjectBody>,
|
||||
) -> Result<Json<ProjectSummary>, (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 project_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 (?, ?, ?, ?) \
|
||||
let project = sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (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(&project_id)
|
||||
.bind(&user_id)
|
||||
.bind(payload.name.trim())
|
||||
.bind(&entrypoint)
|
||||
@@ -316,36 +316,36 @@ pub async fn create_space(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(SpaceSummary {
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
entrypoint: space.entrypoint,
|
||||
Ok(Json(ProjectSummary {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
entrypoint: project.entrypoint,
|
||||
role: "owner".to_string(),
|
||||
updated_at: space.updated_at,
|
||||
updated_at: project.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_space(
|
||||
pub async fn delete_project(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(space_id): Path<String>,
|
||||
Path(project_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)
|
||||
let _ = sqlx::query("DELETE FROM project_files WHERE project_id = ?")
|
||||
.bind(&project_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
let result = sqlx::query("DELETE FROM spaces WHERE id = ? AND owner_id = ?")
|
||||
.bind(&space_id)
|
||||
let result = sqlx::query("DELETE FROM projects WHERE id = ? AND owner_id = ?")
|
||||
.bind(&project_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()));
|
||||
return Err((StatusCode::NOT_FOUND, "Project not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
@@ -361,8 +361,8 @@ pub struct ManifestEntry {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SpaceManifest {
|
||||
pub space_id: String,
|
||||
pub struct ProjectManifest {
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub entrypoint: String,
|
||||
pub updated_at: String,
|
||||
@@ -371,12 +371,12 @@ pub struct SpaceManifest {
|
||||
|
||||
async fn plain_contents(
|
||||
state: &AppState,
|
||||
space_id: &str,
|
||||
project_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",
|
||||
"SELECT path, kind, content, updated_at FROM project_files WHERE project_id = ? ORDER BY path ASC",
|
||||
)
|
||||
.bind(space_id)
|
||||
.bind(project_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
@@ -398,12 +398,12 @@ async fn plain_contents(
|
||||
pub async fn get_manifest(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(space_id): Path<String>,
|
||||
) -> Result<Json<SpaceManifest>, (StatusCode, String)> {
|
||||
Path(project_id): Path<String>,
|
||||
) -> Result<Json<ProjectManifest>, (StatusCode, String)> {
|
||||
let user_id = authenticate(&state, &headers).await?;
|
||||
let space = owned_space(&state, &space_id, &user_id).await?;
|
||||
let project = owned_project(&state, &project_id, &user_id).await?;
|
||||
|
||||
let files = plain_contents(&state, &space_id)
|
||||
let files = plain_contents(&state, &project_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(path, kind, plain, updated_at)| ManifestEntry {
|
||||
@@ -415,11 +415,11 @@ pub async fn get_manifest(
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SpaceManifest {
|
||||
space_id: space.id,
|
||||
name: space.name,
|
||||
entrypoint: space.entrypoint,
|
||||
updated_at: space.updated_at,
|
||||
Ok(Json(ProjectManifest {
|
||||
project_id: project.id,
|
||||
name: project.name,
|
||||
entrypoint: project.entrypoint,
|
||||
updated_at: project.updated_at,
|
||||
files,
|
||||
}))
|
||||
}
|
||||
@@ -452,16 +452,16 @@ fn encode_for_transport(kind: &str, plain: Vec<u8>) -> (String, String) {
|
||||
pub async fn pull_file(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(space_id): Path<String>,
|
||||
Path(project_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?;
|
||||
owned_project(&state, &project_id, &user_id).await?;
|
||||
|
||||
let row = sqlx::query_as::<_, (String, Option<Vec<u8>>)>(
|
||||
"SELECT kind, content FROM space_files WHERE space_id = ? AND path = ?",
|
||||
"SELECT kind, content FROM project_files WHERE project_id = ? AND path = ?",
|
||||
)
|
||||
.bind(&space_id)
|
||||
.bind(&project_id)
|
||||
.bind(&query.path)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
@@ -530,11 +530,11 @@ impl axum::response::IntoResponse for PushOutcome {
|
||||
pub async fn push_file(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(space_id): Path<String>,
|
||||
Path(project_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?;
|
||||
owned_project(&state, &project_id, &user_id).await?;
|
||||
|
||||
let incoming = match payload.encoding.as_deref() {
|
||||
Some("base64") => BASE64
|
||||
@@ -544,9 +544,9 @@ pub async fn push_file(
|
||||
};
|
||||
|
||||
let existing = sqlx::query_as::<_, (String, Option<Vec<u8>>)>(
|
||||
"SELECT kind, content FROM space_files WHERE space_id = ? AND path = ?",
|
||||
"SELECT kind, content FROM project_files WHERE project_id = ? AND path = ?",
|
||||
)
|
||||
.bind(&space_id)
|
||||
.bind(&project_id)
|
||||
.bind(&payload.path)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
@@ -600,13 +600,13 @@ pub async fn push_file(
|
||||
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) \
|
||||
"INSERT INTO project_files (id, project_id, path, kind, content, mime_type, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT (space_id, path) DO UPDATE SET content = excluded.content, \
|
||||
ON CONFLICT (project_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(&project_id)
|
||||
.bind(&payload.path)
|
||||
.bind(kind)
|
||||
.bind(&stored)
|
||||
@@ -616,9 +616,9 @@ pub async fn push_file(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let _ = sqlx::query("UPDATE spaces SET updated_at = ? WHERE id = ?")
|
||||
let _ = sqlx::query("UPDATE projects SET updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&space_id)
|
||||
.bind(&project_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
@@ -632,14 +632,14 @@ pub async fn push_file(
|
||||
pub async fn delete_file(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(space_id): Path<String>,
|
||||
Path(project_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?;
|
||||
owned_project(&state, &project_id, &user_id).await?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM space_files WHERE space_id = ? AND path = ?")
|
||||
.bind(&space_id)
|
||||
let result = sqlx::query("DELETE FROM project_files WHERE project_id = ? AND path = ?")
|
||||
.bind(&project_id)
|
||||
.bind(&query.path)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
@@ -662,22 +662,22 @@ pub struct BundleFile {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SpaceBundle {
|
||||
pub space_id: String,
|
||||
pub struct ProjectBundle {
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub entrypoint: String,
|
||||
pub files: Vec<BundleFile>,
|
||||
}
|
||||
|
||||
pub async fn pull_space(
|
||||
pub async fn pull_project(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(space_id): Path<String>,
|
||||
) -> Result<Json<SpaceBundle>, (StatusCode, String)> {
|
||||
Path(project_id): Path<String>,
|
||||
) -> Result<Json<ProjectBundle>, (StatusCode, String)> {
|
||||
let user_id = authenticate(&state, &headers).await?;
|
||||
let space = owned_space(&state, &space_id, &user_id).await?;
|
||||
let project = owned_project(&state, &project_id, &user_id).await?;
|
||||
|
||||
let files = plain_contents(&state, &space_id)
|
||||
let files = plain_contents(&state, &project_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(path, kind, plain, _)| {
|
||||
@@ -693,10 +693,10 @@ pub async fn pull_space(
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SpaceBundle {
|
||||
space_id: space.id,
|
||||
name: space.name,
|
||||
entrypoint: space.entrypoint,
|
||||
Ok(Json(ProjectBundle {
|
||||
project_id: project.id,
|
||||
name: project.name,
|
||||
entrypoint: project.entrypoint,
|
||||
files,
|
||||
}))
|
||||
}
|
||||
@@ -789,7 +789,7 @@ pub async fn list_documents(
|
||||
#[derive(Serialize)]
|
||||
pub struct SharedItems {
|
||||
pub documents: Vec<CloudDocument>,
|
||||
pub spaces: Vec<SpaceSummary>,
|
||||
pub projects: Vec<ProjectSummary>,
|
||||
}
|
||||
|
||||
pub async fn list_shared(
|
||||
@@ -808,10 +808,10 @@ pub async fn list_shared(
|
||||
.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",
|
||||
let projects = sqlx::query_as::<_, (String, String, String, String, String)>(
|
||||
"SELECT p.id, p.name, p.entrypoint, p.updated_at, c.role FROM projects p \
|
||||
INNER JOIN project_collaborators c ON c.project_id = p.id AND c.user_id = ? \
|
||||
ORDER BY p.updated_at DESC",
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
@@ -829,9 +829,9 @@ pub async fn list_shared(
|
||||
updated_at,
|
||||
})
|
||||
.collect(),
|
||||
spaces: spaces
|
||||
projects: projects
|
||||
.into_iter()
|
||||
.map(|(id, name, entrypoint, updated_at, role)| SpaceSummary {
|
||||
.map(|(id, name, entrypoint, updated_at, role)| ProjectSummary {
|
||||
id,
|
||||
name,
|
||||
entrypoint,
|
||||
@@ -974,6 +974,42 @@ pub async fn push_document(
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateDocumentRequest {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_document(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateDocumentRequest>,
|
||||
) -> Result<Json<DocumentContent>, (StatusCode, String)> {
|
||||
let user_id = authenticate(&state, &headers).await?;
|
||||
let document_id = Uuid::new_v4().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&document_id)
|
||||
.bind(&user_id)
|
||||
.bind(&payload.folder_id)
|
||||
.bind(&payload.title)
|
||||
.bind(encode_text_blob(&payload.content))
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(DocumentContent {
|
||||
id: document_id,
|
||||
title: payload.title,
|
||||
role: "owner".to_string(),
|
||||
hash: content_hash(payload.content.as_bytes()),
|
||||
content: payload.content,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CloudFile {
|
||||
pub id: String,
|
||||
|
||||
+18
-18
@@ -53,7 +53,7 @@ pub struct CompileRequest {
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub space_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub files: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
@@ -102,21 +102,21 @@ pub async fn yjs_handler(
|
||||
// (table, row_id) the autosave task persists into; None means no persistence.
|
||||
let mut save_target: Option<(&'static str, String)> = None;
|
||||
|
||||
if let Some(rest) = id.strip_prefix("space:") {
|
||||
if let Some((space_id, file_id)) = rest.split_once(':') {
|
||||
if let Some((_space, role)) = crate::spaces::space_role(&state, space_id, &user_id_opt).await {
|
||||
if let Some(rest) = id.strip_prefix("project:") {
|
||||
if let Some((project_id, file_id)) = rest.split_once(':') {
|
||||
if let Some((_project, role)) = crate::projects::project_role(&state, project_id, &user_id_opt).await {
|
||||
is_viewer = role == "viewer";
|
||||
if let Ok(Some((content,))) = sqlx::query_as::<_, (Option<Vec<u8>>,)>(
|
||||
"SELECT content FROM space_files WHERE id = ? AND space_id = ?"
|
||||
"SELECT content FROM project_files WHERE id = ? AND project_id = ?"
|
||||
)
|
||||
.bind(file_id)
|
||||
.bind(space_id)
|
||||
.bind(project_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
initial_content = content;
|
||||
}
|
||||
save_target = Some(("space_files", file_id.to_string()));
|
||||
save_target = Some(("project_files", file_id.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -177,8 +177,8 @@ pub async fn yjs_handler(
|
||||
interval.tick().await;
|
||||
let doc = save_awareness.read().await;
|
||||
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
|
||||
let query = if table == "space_files" {
|
||||
"UPDATE space_files SET content = ? WHERE id = ?"
|
||||
let query = if table == "project_files" {
|
||||
"UPDATE project_files SET content = ? WHERE id = ?"
|
||||
} else {
|
||||
"UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||
};
|
||||
@@ -222,8 +222,8 @@ pub async fn compile_handler(
|
||||
let mut can_save_thumbnail = false;
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
|
||||
if let Some(space_id) = &payload.space_id {
|
||||
let (space, role) = match crate::spaces::space_role(&state, space_id, &user_id_opt).await {
|
||||
if let Some(project_id) = &payload.project_id {
|
||||
let (project, role) = match crate::projects::project_role(&state, project_id, &user_id_opt).await {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Json(CompileResponse {
|
||||
@@ -240,7 +240,7 @@ pub async fn compile_handler(
|
||||
};
|
||||
|
||||
let overrides = payload.files.clone().unwrap_or_default();
|
||||
let input = crate::spaces::assemble_project(&state, &space, overrides).await;
|
||||
let input = crate::projects::assemble_project(&state, &project, overrides).await;
|
||||
let can_save = role == "owner" || role == "editor";
|
||||
|
||||
let compiler = state.compiler.lock().await;
|
||||
@@ -250,9 +250,9 @@ pub async fn compile_handler(
|
||||
return match result {
|
||||
Ok((svgs, thumbnail, stats)) => {
|
||||
if can_save {
|
||||
let _ = sqlx::query("UPDATE spaces SET thumbnail_svg = ? WHERE id = ?")
|
||||
let _ = sqlx::query("UPDATE projects SET thumbnail_svg = ? WHERE id = ?")
|
||||
.bind(&thumbnail)
|
||||
.bind(&space.id)
|
||||
.bind(&project.id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
@@ -430,11 +430,11 @@ pub async fn export_handler(
|
||||
}
|
||||
}
|
||||
|
||||
let input = if let Some(space_id) = &payload.space_id {
|
||||
match crate::spaces::space_role(&state, space_id, &user_id_opt).await {
|
||||
Some((space, _)) => {
|
||||
let input = if let Some(project_id) = &payload.project_id {
|
||||
match crate::projects::project_role(&state, project_id, &user_id_opt).await {
|
||||
Some((project, _)) => {
|
||||
let overrides = payload.files.clone().unwrap_or_default();
|
||||
crate::spaces::assemble_project(&state, &space, overrides).await
|
||||
crate::projects::assemble_project(&state, &project, overrides).await
|
||||
}
|
||||
None => return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
|
||||
}
|
||||
|
||||
+12
-12
@@ -24,9 +24,9 @@ mod files;
|
||||
mod handlers;
|
||||
mod models;
|
||||
mod packages;
|
||||
mod projects;
|
||||
mod public_api;
|
||||
mod setup;
|
||||
mod spaces;
|
||||
mod world;
|
||||
mod collab;
|
||||
|
||||
@@ -131,12 +131,12 @@ async fn main() {
|
||||
.route("/keys/usage", get(api_keys::get_aggregate_usage))
|
||||
.route("/keys/{id}", delete(api_keys::delete_key))
|
||||
.route("/keys/{id}/regenerate", post(api_keys::regenerate_key))
|
||||
.route("/spaces/shared", get(spaces::list_shared_spaces))
|
||||
.route("/spaces", get(spaces::list_spaces).post(spaces::create_space))
|
||||
.route("/spaces/{id}", get(spaces::get_space).delete(spaces::delete_space).patch(spaces::update_space))
|
||||
.route("/spaces/{id}/files", get(spaces::list_space_files).post(spaces::create_space_file))
|
||||
.route("/spaces/{id}/files/upload", post(spaces::upload_space_file))
|
||||
.route("/spaces/{id}/files/{fid}", get(spaces::get_space_file).patch(spaces::update_space_file).delete(spaces::delete_space_file))
|
||||
.route("/projects/shared", get(projects::list_shared_projects))
|
||||
.route("/projects", get(projects::list_projects).post(projects::create_project))
|
||||
.route("/projects/{id}", get(projects::get_project).delete(projects::delete_project).patch(projects::update_project))
|
||||
.route("/projects/{id}/files", get(projects::list_project_files).post(projects::create_project_file))
|
||||
.route("/projects/{id}/files/upload", post(projects::upload_project_file))
|
||||
.route("/projects/{id}/files/{fid}", get(projects::get_project_file).patch(projects::update_project_file).delete(projects::delete_project_file))
|
||||
.route("/packages", get(packages::list_packages))
|
||||
.route("/packages/publish", post(packages::publish_package))
|
||||
.route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package));
|
||||
@@ -145,16 +145,16 @@ async fn main() {
|
||||
.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("/projects", get(desktop::list_projects).post(desktop::create_project))
|
||||
.route("/projects/{id}", get(desktop::pull_project).delete(desktop::delete_project))
|
||||
.route("/projects/{id}/manifest", get(desktop::get_manifest))
|
||||
.route("/folders", get(desktop::list_folders))
|
||||
.route("/documents", get(desktop::list_documents))
|
||||
.route("/documents", get(desktop::list_documents).post(desktop::create_document))
|
||||
.route("/documents/{id}", get(desktop::pull_document).put(desktop::push_document))
|
||||
.route("/shared", get(desktop::list_shared))
|
||||
.route("/files", get(desktop::list_account_files))
|
||||
.route("/files/{id}", get(desktop::pull_account_file))
|
||||
.route("/spaces/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file));
|
||||
.route("/projects/{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));
|
||||
|
||||
@@ -73,7 +73,7 @@ pub struct Document {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Space {
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub folder_id: Option<String>,
|
||||
@@ -89,9 +89,9 @@ pub struct Space {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct SpaceFile {
|
||||
pub struct ProjectFile {
|
||||
pub id: String,
|
||||
pub space_id: String,
|
||||
pub project_id: String,
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
#[serde(skip_serializing)]
|
||||
@@ -127,14 +127,14 @@ pub struct PackageVersion {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateSpaceRequest {
|
||||
pub struct CreateProjectRequest {
|
||||
pub name: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub template: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateSpaceRequest {
|
||||
pub struct UpdateProjectRequest {
|
||||
pub name: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub entrypoint: Option<String>,
|
||||
@@ -142,20 +142,20 @@ pub struct UpdateSpaceRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateSpaceFileRequest {
|
||||
pub struct CreateProjectFileRequest {
|
||||
pub path: String,
|
||||
pub kind: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateSpaceFileRequest {
|
||||
pub struct UpdateProjectFileRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PublishPackageRequest {
|
||||
pub space_id: String,
|
||||
pub project_id: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::{Package, PackageVersion, PublishPackageRequest, Space},
|
||||
spaces::decode_text_blob,
|
||||
models::{Package, PackageVersion, Project, PublishPackageRequest},
|
||||
projects::decode_text_blob,
|
||||
AppState,
|
||||
};
|
||||
|
||||
@@ -45,20 +45,20 @@ pub async fn publish_package(
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let space = sqlx::query_as::<_, Space>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ? AND owner_id = ?"
|
||||
let project = sqlx::query_as::<_, Project>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE id = ? AND owner_id = ?"
|
||||
)
|
||||
.bind(&payload.space_id)
|
||||
.bind(&payload.project_id)
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Space not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
|
||||
|
||||
let files = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
|
||||
"SELECT path, kind, content FROM space_files WHERE space_id = ?"
|
||||
"SELECT path, kind, content FROM project_files WHERE project_id = ?"
|
||||
)
|
||||
.bind(&space.id)
|
||||
.bind(&project.id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
@@ -78,7 +78,7 @@ pub async fn publish_package(
|
||||
}
|
||||
|
||||
let manifest_text = manifest_text
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Space has no typst.toml manifest".to_string()))?;
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Project has no typst.toml manifest".to_string()))?;
|
||||
let manifest: Manifest = toml::from_str(&manifest_text)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid typst.toml: {}", e)))?;
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ use yrs::Update;
|
||||
use crate::{
|
||||
compiler::ProjectInput,
|
||||
models::{
|
||||
CreateSpaceFileRequest, CreateSpaceRequest, Space, SpaceFile, UpdateSpaceFileRequest,
|
||||
UpdateSpaceRequest,
|
||||
CreateProjectFileRequest, CreateProjectRequest, Project, ProjectFile, UpdateProjectFileRequest,
|
||||
UpdateProjectRequest,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
@@ -61,44 +61,44 @@ fn slugify(name: &str) -> String {
|
||||
.collect();
|
||||
let trimmed = slug.trim_matches('-').replace("--", "-");
|
||||
if trimmed.is_empty() {
|
||||
"my-space".to_string()
|
||||
"my-project".to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn space_role(
|
||||
pub async fn project_role(
|
||||
state: &AppState,
|
||||
space_id: &str,
|
||||
project_id: &str,
|
||||
user_id_opt: &Option<String>,
|
||||
) -> Option<(Space, String)> {
|
||||
let space = sqlx::query_as::<_, Space>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ?"
|
||||
) -> Option<(Project, String)> {
|
||||
let project = sqlx::query_as::<_, Project>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE id = ?"
|
||||
)
|
||||
.bind(space_id)
|
||||
.bind(project_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()??;
|
||||
|
||||
if let Some(uid) = user_id_opt {
|
||||
if &space.owner_id == uid {
|
||||
return Some((space, "owner".to_string()));
|
||||
if &project.owner_id == uid {
|
||||
return Some((project, "owner".to_string()));
|
||||
}
|
||||
if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT role FROM space_collaborators WHERE space_id = ? AND user_id = ?",
|
||||
"SELECT role FROM project_collaborators WHERE project_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(space_id)
|
||||
.bind(project_id)
|
||||
.bind(uid)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
return Some((space, role));
|
||||
return Some((project, role));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pr) = space.public_role.clone() {
|
||||
if let Some(pr) = project.public_role.clone() {
|
||||
if pr == "viewer" || pr == "editor" {
|
||||
return Some((space, pr));
|
||||
return Some((project, pr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,17 +128,17 @@ pub async fn load_local_packages(state: &AppState) -> HashMap<String, HashMap<St
|
||||
|
||||
pub async fn assemble_project(
|
||||
state: &AppState,
|
||||
space: &Space,
|
||||
project: &Project,
|
||||
overrides: HashMap<String, String>,
|
||||
) -> ProjectInput {
|
||||
let mut files: HashMap<String, Vec<u8>> = HashMap::new();
|
||||
|
||||
// Account-level uploaded files (fonts, images) come first as a base layer so
|
||||
// they are available inside spaces; space files below override them by name.
|
||||
// they are available inside projects; project files below override them by name.
|
||||
if let Ok(account_files) = sqlx::query_as::<_, (String, Vec<u8>)>(
|
||||
"SELECT name, data FROM files WHERE owner_id = ?",
|
||||
)
|
||||
.bind(&space.owner_id)
|
||||
.bind(&project.owner_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
{
|
||||
@@ -148,9 +148,9 @@ pub async fn assemble_project(
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
|
||||
"SELECT path, kind, content FROM space_files WHERE space_id = ?",
|
||||
"SELECT path, kind, content FROM project_files WHERE project_id = ?",
|
||||
)
|
||||
.bind(&space.id)
|
||||
.bind(&project.id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -170,36 +170,36 @@ pub async fn assemble_project(
|
||||
}
|
||||
|
||||
ProjectInput {
|
||||
entrypoint: space.entrypoint.clone(),
|
||||
entrypoint: project.entrypoint.clone(),
|
||||
files,
|
||||
packages: load_local_packages(state).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ListSpacesQuery {
|
||||
pub struct ListProjectsQuery {
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_spaces(
|
||||
Query(query): Query<ListSpacesQuery>,
|
||||
pub async fn list_projects(
|
||||
Query(query): Query<ListProjectsQuery>,
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<Space>>, (StatusCode, String)> {
|
||||
) -> Result<Json<Vec<Project>>, (StatusCode, String)> {
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let spaces = if let Some(folder_id) = query.folder_id {
|
||||
sqlx::query_as::<_, Space>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC"
|
||||
let projects = if let Some(folder_id) = query.folder_id {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&folder_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, Space>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC"
|
||||
sqlx::query_as::<_, Project>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
@@ -207,45 +207,45 @@ pub async fn list_spaces(
|
||||
}
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(spaces))
|
||||
Ok(Json(projects))
|
||||
}
|
||||
|
||||
pub async fn list_shared_spaces(
|
||||
pub async fn list_shared_projects(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<Space>>, (StatusCode, String)> {
|
||||
) -> Result<Json<Vec<Project>>, (StatusCode, String)> {
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let spaces = 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, c.role as effective_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"
|
||||
let projects = sqlx::query_as::<_, Project>(
|
||||
"SELECT p.id, p.owner_id, p.folder_id, p.name, p.entrypoint, p.thumbnail_svg, \
|
||||
p.public_role, p.created_at, p.updated_at, c.role as effective_role \
|
||||
FROM projects p \
|
||||
INNER JOIN project_collaborators c ON c.project_id = p.id AND c.user_id = ? \
|
||||
ORDER BY p.updated_at DESC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(spaces))
|
||||
Ok(Json(projects))
|
||||
}
|
||||
|
||||
pub async fn create_space(
|
||||
pub async fn create_project(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateSpaceRequest>,
|
||||
) -> Result<Json<Space>, (StatusCode, String)> {
|
||||
Json(payload): Json<CreateProjectRequest>,
|
||||
) -> Result<Json<Project>, (StatusCode, String)> {
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let space_id = Uuid::new_v4().to_string();
|
||||
let project_id = Uuid::new_v4().to_string();
|
||||
|
||||
let space = sqlx::query_as::<_, Space>(
|
||||
"INSERT INTO spaces (id, owner_id, folder_id, name, entrypoint) VALUES (?, ?, ?, ?, 'main.typ') RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at"
|
||||
let project = sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (id, owner_id, folder_id, name, entrypoint) VALUES (?, ?, ?, ?, 'main.typ') RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at"
|
||||
)
|
||||
.bind(&space_id)
|
||||
.bind(&project_id)
|
||||
.bind(&user_id)
|
||||
.bind(&payload.folder_id)
|
||||
.bind(&payload.name)
|
||||
@@ -255,91 +255,91 @@ pub async fn create_space(
|
||||
|
||||
let seeds = [
|
||||
("typst.toml", default_manifest(&slugify(&payload.name))),
|
||||
("main.typ", "= New Space\n\nStart writing here.\n".to_string()),
|
||||
("main.typ", "= New Project\n\nStart writing here.\n".to_string()),
|
||||
];
|
||||
for (path, content) in seeds {
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, 'text', ?, 'text/plain')"
|
||||
"INSERT INTO project_files (id, project_id, path, kind, content, mime_type) VALUES (?, ?, ?, 'text', ?, 'text/plain')"
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&space_id)
|
||||
.bind(&project_id)
|
||||
.bind(path)
|
||||
.bind(encode_text_blob(&content))
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(Json(space))
|
||||
Ok(Json(project))
|
||||
}
|
||||
|
||||
pub async fn get_space(
|
||||
pub async fn get_project(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Space>, (StatusCode, String)> {
|
||||
) -> Result<Json<Project>, (StatusCode, String)> {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
|
||||
let (mut space, role) = space_role(&state, &id, &user_id_opt)
|
||||
let (mut project, role) = project_role(&state, &id, &user_id_opt)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
|
||||
space.effective_role = Some(role);
|
||||
Ok(Json(space))
|
||||
project.effective_role = Some(role);
|
||||
Ok(Json(project))
|
||||
}
|
||||
|
||||
pub async fn update_space(
|
||||
pub async fn update_project(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateSpaceRequest>,
|
||||
) -> Result<Json<Space>, (StatusCode, String)> {
|
||||
Json(payload): Json<UpdateProjectRequest>,
|
||||
) -> Result<Json<Project>, (StatusCode, String)> {
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let mut space = sqlx::query_as::<_, Space>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ? AND owner_id = ?"
|
||||
let mut project = sqlx::query_as::<_, Project>(
|
||||
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE id = ? AND owner_id = ?"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Space not found".to_string()))?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
|
||||
|
||||
if let Some(name) = payload.name {
|
||||
space.name = name;
|
||||
project.name = name;
|
||||
}
|
||||
if let Some(entrypoint) = payload.entrypoint {
|
||||
space.entrypoint = entrypoint;
|
||||
project.entrypoint = entrypoint;
|
||||
}
|
||||
if let Some(folder_id) = payload.folder_id {
|
||||
space.folder_id = if folder_id.is_empty() { None } else { Some(folder_id) };
|
||||
project.folder_id = if folder_id.is_empty() { None } else { Some(folder_id) };
|
||||
}
|
||||
if let Some(public_role) = payload.public_role {
|
||||
space.public_role = if public_role == "none" || public_role.is_empty() {
|
||||
project.public_role = if public_role == "none" || public_role.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(public_role)
|
||||
};
|
||||
}
|
||||
|
||||
let space = sqlx::query_as::<_, Space>(
|
||||
"UPDATE spaces SET name = ?, entrypoint = ?, folder_id = ?, public_role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at"
|
||||
let project = sqlx::query_as::<_, Project>(
|
||||
"UPDATE projects SET name = ?, entrypoint = ?, folder_id = ?, public_role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at"
|
||||
)
|
||||
.bind(&space.name)
|
||||
.bind(&space.entrypoint)
|
||||
.bind(&space.folder_id)
|
||||
.bind(&space.public_role)
|
||||
.bind(&project.name)
|
||||
.bind(&project.entrypoint)
|
||||
.bind(&project.folder_id)
|
||||
.bind(&project.public_role)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(space))
|
||||
Ok(Json(project))
|
||||
}
|
||||
|
||||
pub async fn delete_space(
|
||||
pub async fn delete_project(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
@@ -347,12 +347,12 @@ pub async fn delete_space(
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let _ = sqlx::query("DELETE FROM space_files WHERE space_id = ?")
|
||||
let _ = sqlx::query("DELETE FROM project_files WHERE project_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
let result = sqlx::query("DELETE FROM spaces WHERE id = ? AND owner_id = ?")
|
||||
let result = sqlx::query("DELETE FROM projects WHERE id = ? AND owner_id = ?")
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.execute(&state.db)
|
||||
@@ -360,24 +360,24 @@ pub async fn delete_space(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Space not found or unauthorized".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Project not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_space_files(
|
||||
pub async fn list_project_files(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<SpaceFile>>, (StatusCode, String)> {
|
||||
) -> Result<Json<Vec<ProjectFile>>, (StatusCode, String)> {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
space_role(&state, &id, &user_id_opt)
|
||||
project_role(&state, &id, &user_id_opt)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
|
||||
let files = sqlx::query_as::<_, SpaceFile>(
|
||||
"SELECT id, space_id, path, kind, mime_type, created_at FROM space_files WHERE space_id = ? ORDER BY path ASC"
|
||||
let files = sqlx::query_as::<_, ProjectFile>(
|
||||
"SELECT id, project_id, path, kind, mime_type, created_at FROM project_files WHERE project_id = ? ORDER BY path ASC"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_all(&state.db)
|
||||
@@ -387,14 +387,14 @@ pub async fn list_space_files(
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
pub async fn create_space_file(
|
||||
pub async fn create_project_file(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateSpaceFileRequest>,
|
||||
) -> Result<Json<SpaceFile>, (StatusCode, String)> {
|
||||
Json(payload): Json<CreateProjectFileRequest>,
|
||||
) -> Result<Json<ProjectFile>, (StatusCode, String)> {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
let (_, role) = space_role(&state, &id, &user_id_opt)
|
||||
let (_, role) = project_role(&state, &id, &user_id_opt)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
if role == "viewer" {
|
||||
@@ -405,8 +405,8 @@ pub async fn create_space_file(
|
||||
let content = payload.content.unwrap_or_default();
|
||||
let file_id = Uuid::new_v4().to_string();
|
||||
|
||||
let file = sqlx::query_as::<_, SpaceFile>(
|
||||
"INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, 'text/plain') RETURNING id, space_id, path, kind, mime_type, created_at"
|
||||
let file = sqlx::query_as::<_, ProjectFile>(
|
||||
"INSERT INTO project_files (id, project_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, 'text/plain') RETURNING id, project_id, path, kind, mime_type, created_at"
|
||||
)
|
||||
.bind(&file_id)
|
||||
.bind(&id)
|
||||
@@ -420,14 +420,14 @@ pub async fn create_space_file(
|
||||
Ok(Json(file))
|
||||
}
|
||||
|
||||
pub async fn upload_space_file(
|
||||
pub async fn upload_project_file(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
let (_, role) = space_role(&state, &id, &user_id_opt)
|
||||
let (_, role) = project_role(&state, &id, &user_id_opt)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
if role == "viewer" {
|
||||
@@ -449,8 +449,8 @@ pub async fn upload_space_file(
|
||||
};
|
||||
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT (space_id, path) DO UPDATE SET content = excluded.content, kind = excluded.kind, mime_type = excluded.mime_type"
|
||||
"INSERT INTO project_files (id, project_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT (project_id, path) DO UPDATE SET content = excluded.content, kind = excluded.kind, mime_type = excluded.mime_type"
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&id)
|
||||
@@ -468,18 +468,18 @@ pub async fn upload_space_file(
|
||||
Ok(Json(serde_json::json!({ "files": uploaded })))
|
||||
}
|
||||
|
||||
pub async fn get_space_file(
|
||||
pub async fn get_project_file(
|
||||
State(state): State<AppState>,
|
||||
Path((id, file_id)): Path<(String, String)>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
space_role(&state, &id, &user_id_opt)
|
||||
project_role(&state, &id, &user_id_opt)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
|
||||
let file = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
|
||||
"SELECT kind, mime_type, content FROM space_files WHERE id = ? AND space_id = ?"
|
||||
"SELECT kind, mime_type, content FROM project_files WHERE id = ? AND project_id = ?"
|
||||
)
|
||||
.bind(&file_id)
|
||||
.bind(&id)
|
||||
@@ -498,21 +498,21 @@ pub async fn get_space_file(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_space_file(
|
||||
pub async fn update_project_file(
|
||||
State(state): State<AppState>,
|
||||
Path((id, file_id)): Path<(String, String)>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateSpaceFileRequest>,
|
||||
Json(payload): Json<UpdateProjectFileRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
let (_, role) = space_role(&state, &id, &user_id_opt)
|
||||
let (_, role) = project_role(&state, &id, &user_id_opt)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
if role == "viewer" {
|
||||
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query("UPDATE space_files SET path = ? WHERE id = ? AND space_id = ?")
|
||||
let result = sqlx::query("UPDATE project_files SET path = ? WHERE id = ? AND project_id = ?")
|
||||
.bind(&payload.path)
|
||||
.bind(&file_id)
|
||||
.bind(&id)
|
||||
@@ -527,20 +527,20 @@ pub async fn update_space_file(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn delete_space_file(
|
||||
pub async fn delete_project_file(
|
||||
State(state): State<AppState>,
|
||||
Path((id, file_id)): Path<(String, String)>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
let (_, role) = space_role(&state, &id, &user_id_opt)
|
||||
let (_, role) = project_role(&state, &id, &user_id_opt)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
if role == "viewer" {
|
||||
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query("DELETE FROM space_files WHERE id = ? AND space_id = ?")
|
||||
let result = sqlx::query("DELETE FROM project_files WHERE id = ? AND project_id = ?")
|
||||
.bind(&file_id)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
Reference in New Issue
Block a user