Added Typst Spaces and Internal Packages
This commit is contained in:
+28
-9
@@ -50,6 +50,28 @@ fn extract_frame_text(frame: &Frame, text: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProjectInput {
|
||||
pub entrypoint: String,
|
||||
pub files: HashMap<String, Vec<u8>>,
|
||||
pub packages: HashMap<String, HashMap<String, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl ProjectInput {
|
||||
pub fn single(text: String, files: HashMap<String, Vec<u8>>) -> Self {
|
||||
let mut project_files = files;
|
||||
project_files.insert("main.typ".to_string(), text.into_bytes());
|
||||
Self {
|
||||
entrypoint: "main.typ".to_string(),
|
||||
files: project_files,
|
||||
packages: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_world(self) -> MemoryWorld {
|
||||
MemoryWorld::new_project(self.entrypoint, self.files, self.packages)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TypstCompiler;
|
||||
|
||||
impl TypstCompiler {
|
||||
@@ -59,13 +81,12 @@ impl TypstCompiler {
|
||||
|
||||
pub fn compile_svg(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
input: ProjectInput,
|
||||
) -> Result<
|
||||
(Vec<String>, String, DocumentStats),
|
||||
Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>,
|
||||
> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
let world = input.into_world();
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
@@ -104,10 +125,9 @@ impl TypstCompiler {
|
||||
|
||||
pub fn export_pdf(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
input: ProjectInput,
|
||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
let world = input.into_world();
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
@@ -137,10 +157,9 @@ impl TypstCompiler {
|
||||
|
||||
pub fn export_png(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
input: ProjectInput,
|
||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
let world = input.into_world();
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
|
||||
@@ -105,6 +105,60 @@ pub async fn init_schema(pool: &AnyPool) {
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY(key_id, minute)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
folder_id TEXT REFERENCES folders(id),
|
||||
name TEXT NOT NULL,
|
||||
entrypoint TEXT NOT NULL DEFAULT 'main.typ',
|
||||
thumbnail_svg TEXT,
|
||||
public_role TEXT DEFAULT NULL,
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(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)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS space_collaborators (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(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)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS packages (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
namespace TEXT NOT NULL DEFAULT 'typstdrive',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
UNIQUE(namespace, name)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS package_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
|
||||
version TEXT NOT NULL,
|
||||
entrypoint TEXT NOT NULL DEFAULT 'lib.typ',
|
||||
manifest BYTEA,
|
||||
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
UNIQUE(package_id, version)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS package_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
version_id TEXT NOT NULL REFERENCES package_versions(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
data BYTEA NOT NULL,
|
||||
UNIQUE(version_id, path)
|
||||
)",
|
||||
];
|
||||
|
||||
for stmt in &statements {
|
||||
|
||||
@@ -110,6 +110,60 @@ pub async fn init_schema(pool: &AnyPool) {
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY(key_id, minute)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
folder_id TEXT REFERENCES folders(id),
|
||||
name TEXT NOT NULL,
|
||||
entrypoint TEXT NOT NULL DEFAULT 'main.typ',
|
||||
thumbnail_svg TEXT,
|
||||
public_role TEXT DEFAULT NULL,
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(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)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS space_collaborators (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(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)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS packages (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
namespace TEXT NOT NULL DEFAULT 'typstdrive',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
|
||||
UNIQUE(namespace, name)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS package_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
|
||||
version TEXT NOT NULL,
|
||||
entrypoint TEXT NOT NULL DEFAULT 'lib.typ',
|
||||
manifest BLOB,
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
|
||||
UNIQUE(package_id, version)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS package_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
version_id TEXT NOT NULL REFERENCES package_versions(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
UNIQUE(version_id, path)
|
||||
)",
|
||||
];
|
||||
|
||||
for stmt in &statements {
|
||||
|
||||
+154
-47
@@ -50,11 +50,29 @@ impl Stream for ViewerFilterStream {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CompileRequest {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub space_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub files: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
use crate::compiler::DocumentStats;
|
||||
use crate::compiler::{DocumentStats, ProjectInput};
|
||||
|
||||
fn map_diagnostics(
|
||||
diags: Vec<(typst::diag::SourceDiagnostic, Option<std::ops::Range<usize>>)>,
|
||||
) -> Vec<Diagnostic> {
|
||||
diags
|
||||
.into_iter()
|
||||
.map(|(d, range)| Diagnostic {
|
||||
message: d.message.to_string(),
|
||||
severity: format!("{:?}", d.severity),
|
||||
from: range.as_ref().map(|r| r.start),
|
||||
to: range.as_ref().map(|r| r.end),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CompileResponse {
|
||||
@@ -79,34 +97,59 @@ pub async fn yjs_handler(
|
||||
) -> impl IntoResponse {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
|
||||
let doc_info = sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let mut is_viewer = true;
|
||||
if let Ok(Some(ref d)) = doc_info {
|
||||
if let Some(uid) = &user_id_opt {
|
||||
if &d.owner_id == uid {
|
||||
is_viewer = false;
|
||||
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ? AND role = 'editor'")
|
||||
.bind(&id)
|
||||
.bind(uid)
|
||||
let mut initial_content: Option<Vec<u8>> = None;
|
||||
// (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 {
|
||||
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 = ?"
|
||||
)
|
||||
.bind(file_id)
|
||||
.bind(space_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
is_viewer = false;
|
||||
{
|
||||
initial_content = content;
|
||||
}
|
||||
save_target = Some(("space_files", file_id.to_string()));
|
||||
}
|
||||
}
|
||||
if is_viewer {
|
||||
if let Some(pr) = &d.public_role {
|
||||
if pr == "editor" {
|
||||
} else {
|
||||
let doc_info = sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
if let Ok(Some(ref d)) = doc_info {
|
||||
if let Some(uid) = &user_id_opt {
|
||||
if &d.owner_id == uid {
|
||||
is_viewer = false;
|
||||
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ? AND role = 'editor'")
|
||||
.bind(&id)
|
||||
.bind(uid)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
is_viewer = false;
|
||||
}
|
||||
}
|
||||
if is_viewer {
|
||||
if let Some(pr) = &d.public_role {
|
||||
if pr == "editor" {
|
||||
is_viewer = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
initial_content = d.content.clone();
|
||||
}
|
||||
save_target = Some(("documents", id.clone()));
|
||||
}
|
||||
|
||||
let mut bcast_map = state.bcast_map.lock().await;
|
||||
@@ -115,11 +158,9 @@ pub async fn yjs_handler(
|
||||
} else {
|
||||
let ydoc = Doc::new();
|
||||
|
||||
if let Ok(Some(db_doc)) = doc_info {
|
||||
if let Some(content) = db_doc.content {
|
||||
if let Ok(update) = Update::decode_v1(&content) {
|
||||
ydoc.transact_mut().apply_update(update);
|
||||
}
|
||||
if let Some(content) = initial_content {
|
||||
if let Ok(update) = Update::decode_v1(&content) {
|
||||
ydoc.transact_mut().apply_update(update);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,22 +168,28 @@ pub async fn yjs_handler(
|
||||
let new_bcast = Arc::new(BroadcastGroup::new(awareness.clone(), 10).await);
|
||||
bcast_map.insert(id.clone(), new_bcast.clone());
|
||||
|
||||
let save_db = state.db.clone();
|
||||
let save_id = id.clone();
|
||||
let save_awareness = awareness.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let doc = save_awareness.read().await;
|
||||
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
|
||||
let _ = sqlx::query("UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(&save_id)
|
||||
.execute(&save_db)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
if let Some((table, row_id)) = save_target {
|
||||
let save_db = state.db.clone();
|
||||
let save_awareness = awareness.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
||||
loop {
|
||||
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 = ?"
|
||||
} else {
|
||||
"UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||
};
|
||||
let _ = sqlx::query(query)
|
||||
.bind(content)
|
||||
.bind(&row_id)
|
||||
.execute(&save_db)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
new_bcast
|
||||
};
|
||||
@@ -175,6 +222,54 @@ 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 {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Json(CompileResponse {
|
||||
svgs: None,
|
||||
errors: Some(vec![Diagnostic {
|
||||
message: "Unauthorized".to_string(),
|
||||
severity: "Error".to_string(),
|
||||
from: None,
|
||||
to: None,
|
||||
}]),
|
||||
stats: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let overrides = payload.files.clone().unwrap_or_default();
|
||||
let input = crate::spaces::assemble_project(&state, &space, overrides).await;
|
||||
let can_save = role == "owner" || role == "editor";
|
||||
|
||||
let compiler = state.compiler.lock().await;
|
||||
let result = compiler.compile_svg(input);
|
||||
drop(compiler);
|
||||
|
||||
return match result {
|
||||
Ok((svgs, thumbnail, stats)) => {
|
||||
if can_save {
|
||||
let _ = sqlx::query("UPDATE spaces SET thumbnail_svg = ? WHERE id = ?")
|
||||
.bind(&thumbnail)
|
||||
.bind(&space.id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
Json(CompileResponse {
|
||||
svgs: Some(svgs),
|
||||
errors: None,
|
||||
stats: Some(stats),
|
||||
})
|
||||
}
|
||||
Err(diags) => Json(CompileResponse {
|
||||
svgs: None,
|
||||
errors: Some(map_diagnostics(diags)),
|
||||
stats: None,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(doc_id) = &payload.document_id {
|
||||
if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
|
||||
@@ -234,7 +329,7 @@ pub async fn compile_handler(
|
||||
}
|
||||
|
||||
let compiler = state.compiler.lock().await;
|
||||
match compiler.compile_svg(payload.text, files_map) {
|
||||
match compiler.compile_svg(ProjectInput::single(payload.text.clone().unwrap_or_default(), files_map)) {
|
||||
Ok((svgs, thumbnail, stats)) => {
|
||||
if let Some(doc_id) = &payload.document_id {
|
||||
if can_save_thumbnail {
|
||||
@@ -335,10 +430,22 @@ 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 overrides = payload.files.clone().unwrap_or_default();
|
||||
crate::spaces::assemble_project(&state, &space, overrides).await
|
||||
}
|
||||
None => return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
|
||||
}
|
||||
} else {
|
||||
ProjectInput::single(payload.text.clone().unwrap_or_default(), files_map)
|
||||
};
|
||||
|
||||
let compiler = state.compiler.lock().await;
|
||||
|
||||
match format.as_str() {
|
||||
"pdf" => match compiler.export_pdf(payload.text, files_map.clone()) {
|
||||
"pdf" => match compiler.export_pdf(input) {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/pdf")],
|
||||
@@ -347,7 +454,7 @@ pub async fn export_handler(
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
|
||||
},
|
||||
"png" => match compiler.export_png(payload.text, files_map.clone()) {
|
||||
"png" => match compiler.export_png(input) {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "image/png")],
|
||||
@@ -356,7 +463,7 @@ pub async fn export_handler(
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
|
||||
},
|
||||
"svg" => match compiler.compile_svg(payload.text, files_map.clone()) {
|
||||
"svg" => match compiler.compile_svg(input) {
|
||||
Ok((svgs, _, _)) => {
|
||||
let mut combined = String::new();
|
||||
for svg in svgs {
|
||||
@@ -408,7 +515,7 @@ pub async fn pandoc_export_handler(
|
||||
};
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let text = payload.text.clone();
|
||||
let text = payload.text.clone().unwrap_or_default();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = stdin.write_all(text.as_bytes()).await;
|
||||
|
||||
+12
-1
@@ -22,8 +22,10 @@ mod folders;
|
||||
mod files;
|
||||
mod handlers;
|
||||
mod models;
|
||||
mod packages;
|
||||
mod public_api;
|
||||
mod setup;
|
||||
mod spaces;
|
||||
mod world;
|
||||
mod collab;
|
||||
|
||||
@@ -127,7 +129,16 @@ async fn main() {
|
||||
.route("/keys", get(api_keys::list_keys).post(api_keys::create_key))
|
||||
.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("/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("/packages", get(packages::list_packages))
|
||||
.route("/packages/publish", post(packages::publish_package))
|
||||
.route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package));
|
||||
|
||||
let v1_routes = Router::new()
|
||||
.route("/render", post(public_api::render_handler));
|
||||
|
||||
@@ -72,6 +72,93 @@ pub struct Document {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Space {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub name: String,
|
||||
pub entrypoint: String,
|
||||
pub thumbnail_svg: Option<String>,
|
||||
pub public_role: Option<String>,
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
pub effective_role: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct SpaceFile {
|
||||
pub id: String,
|
||||
pub space_id: String,
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
#[serde(skip_serializing)]
|
||||
#[sqlx(default)]
|
||||
pub content: Option<Vec<u8>>,
|
||||
pub mime_type: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Package {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub namespace: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
pub owner_name: Option<String>,
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
pub latest_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct PackageVersion {
|
||||
pub id: String,
|
||||
pub package_id: String,
|
||||
pub version: String,
|
||||
pub entrypoint: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateSpaceRequest {
|
||||
pub name: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub template: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateSpaceRequest {
|
||||
pub name: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub entrypoint: Option<String>,
|
||||
pub public_role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateSpaceFileRequest {
|
||||
pub path: String,
|
||||
pub kind: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateSpaceFileRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PublishPackageRequest {
|
||||
pub space_id: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::SignedCookieJar;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::{Package, PackageVersion, PublishPackageRequest, Space},
|
||||
spaces::decode_text_blob,
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
package: PackageMeta,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PackageMeta {
|
||||
name: String,
|
||||
version: String,
|
||||
entrypoint: Option<String>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
fn is_valid_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
fn is_valid_version(version: &str) -> bool {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
pub async fn publish_package(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<PublishPackageRequest>,
|
||||
) -> Result<Json<Package>, (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 = 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 = ?"
|
||||
)
|
||||
.bind(&payload.space_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()))?;
|
||||
|
||||
let files = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
|
||||
"SELECT path, kind, content FROM space_files WHERE space_id = ?"
|
||||
)
|
||||
.bind(&space.id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let mut snapshot: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
let mut manifest_text: Option<String> = None;
|
||||
for (path, kind, content) in files {
|
||||
let bytes = if kind == "binary" {
|
||||
content.unwrap_or_default()
|
||||
} else {
|
||||
decode_text_blob(&content.unwrap_or_default()).into_bytes()
|
||||
};
|
||||
if path == "typst.toml" {
|
||||
manifest_text = Some(String::from_utf8_lossy(&bytes).to_string());
|
||||
}
|
||||
snapshot.push((path, bytes));
|
||||
}
|
||||
|
||||
let manifest_text = manifest_text
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Space 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)))?;
|
||||
|
||||
let name = manifest.package.name.trim().to_string();
|
||||
let version = payload.version.unwrap_or(manifest.package.version).trim().to_string();
|
||||
let entrypoint = manifest.package.entrypoint.unwrap_or_else(|| "lib.typ".to_string());
|
||||
|
||||
if !is_valid_name(&name) {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid package name (lowercase letters, digits, '-' and '_' only)".to_string()));
|
||||
}
|
||||
if !is_valid_version(&version) {
|
||||
return Err((StatusCode::BAD_REQUEST, "Version must be in the form major.minor.patch".to_string()));
|
||||
}
|
||||
|
||||
let existing = sqlx::query_as::<_, Package>(
|
||||
"SELECT id, owner_id, namespace, name, description, created_at FROM packages WHERE namespace = 'typstdrive' AND name = ?"
|
||||
)
|
||||
.bind(&name)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let package = match existing {
|
||||
Some(pkg) => {
|
||||
if pkg.owner_id != user_id {
|
||||
return Err((StatusCode::FORBIDDEN, "A package with this name is owned by another user".to_string()));
|
||||
}
|
||||
pkg
|
||||
}
|
||||
None => {
|
||||
let package_id = Uuid::new_v4().to_string();
|
||||
sqlx::query_as::<_, Package>(
|
||||
"INSERT INTO packages (id, owner_id, namespace, name, description) VALUES (?, ?, 'typstdrive', ?, ?) RETURNING id, owner_id, namespace, name, description, created_at"
|
||||
)
|
||||
.bind(&package_id)
|
||||
.bind(&user_id)
|
||||
.bind(&name)
|
||||
.bind(&manifest.package.description)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
}
|
||||
};
|
||||
|
||||
let version_exists = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT id FROM package_versions WHERE package_id = ? AND version = ?"
|
||||
)
|
||||
.bind(&package.id)
|
||||
.bind(&version)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if version_exists.is_some() {
|
||||
return Err((StatusCode::CONFLICT, format!("Version {} already published; versions are immutable", version)));
|
||||
}
|
||||
|
||||
let version_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO package_versions (id, package_id, version, entrypoint, manifest) VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&version_id)
|
||||
.bind(&package.id)
|
||||
.bind(&version)
|
||||
.bind(&entrypoint)
|
||||
.bind(manifest_text.into_bytes())
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
for (path, data) in snapshot {
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO package_files (id, version_id, path, data) VALUES (?, ?, ?, ?)"
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&version_id)
|
||||
.bind(&path)
|
||||
.bind(&data)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(Json(package))
|
||||
}
|
||||
|
||||
pub async fn list_packages(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<Package>>, (StatusCode, String)> {
|
||||
jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let packages = sqlx::query_as::<_, Package>(
|
||||
"SELECT p.id, p.owner_id, p.namespace, p.name, p.description, p.created_at, \
|
||||
u.username as owner_name, \
|
||||
(SELECT v.version FROM package_versions v WHERE v.package_id = p.id ORDER BY v.created_at DESC LIMIT 1) as latest_version \
|
||||
FROM packages p JOIN users u ON u.id = p.owner_id \
|
||||
ORDER BY p.name ASC"
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(packages))
|
||||
}
|
||||
|
||||
pub async fn list_versions(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<PackageVersion>>, (StatusCode, String)> {
|
||||
jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let versions = sqlx::query_as::<_, PackageVersion>(
|
||||
"SELECT v.id, v.package_id, v.version, v.entrypoint, v.created_at \
|
||||
FROM package_versions v JOIN packages p ON p.id = v.package_id \
|
||||
WHERE p.namespace = 'typstdrive' AND p.name = ? ORDER BY v.created_at DESC"
|
||||
)
|
||||
.bind(&name)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(versions))
|
||||
}
|
||||
|
||||
pub async fn delete_package(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<StatusCode, (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 is_admin = sqlx::query_as::<_, (i64,)>("SELECT is_admin FROM users WHERE id = ?")
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.map(|(a,)| a != 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let result = if is_admin {
|
||||
sqlx::query("DELETE FROM packages WHERE namespace = 'typstdrive' AND name = ?")
|
||||
.bind(&name)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query("DELETE FROM packages WHERE namespace = 'typstdrive' AND name = ? AND owner_id = ?")
|
||||
.bind(&name)
|
||||
.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, "Package not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use sha2::{Sha256, Digest};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{api_keys::hash_key, AppState};
|
||||
use crate::{api_keys::hash_key, compiler::ProjectInput, AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RenderRequest {
|
||||
@@ -214,8 +214,8 @@ pub async fn render_handler(
|
||||
// Compile
|
||||
let compiler = state.compiler.lock().await;
|
||||
let result = match payload.format.as_str() {
|
||||
"pdf" => compiler.export_pdf(payload.code.clone(), files_map),
|
||||
"png" => compiler.export_png(payload.code.clone(), files_map),
|
||||
"pdf" => compiler.export_pdf(ProjectInput::single(payload.code.clone(), files_map)),
|
||||
"png" => compiler.export_png(ProjectInput::single(payload.code.clone(), files_map)),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
drop(compiler);
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State, Multipart},
|
||||
http::{header, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::SignedCookieJar;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use yrs::{Doc, GetString, ReadTxn, StateVector, Text, Transact};
|
||||
use yrs::updates::decoder::Decode;
|
||||
use yrs::Update;
|
||||
|
||||
use crate::{
|
||||
compiler::ProjectInput,
|
||||
models::{
|
||||
CreateSpaceFileRequest, CreateSpaceRequest, Space, SpaceFile, UpdateSpaceFileRequest,
|
||||
UpdateSpaceRequest,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
|
||||
const TEXT_NAME: &str = "typst";
|
||||
|
||||
pub fn encode_text_blob(text: &str) -> Vec<u8> {
|
||||
let doc = Doc::new();
|
||||
let handle = doc.get_or_insert_text(TEXT_NAME);
|
||||
handle.insert(&mut doc.transact_mut(), 0, text);
|
||||
let bytes = doc.transact().encode_state_as_update_v1(&StateVector::default());
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn decode_text_blob(blob: &[u8]) -> String {
|
||||
let doc = Doc::new();
|
||||
if let Ok(update) = Update::decode_v1(blob) {
|
||||
doc.transact_mut().apply_update(update);
|
||||
}
|
||||
let handle = doc.get_or_insert_text(TEXT_NAME);
|
||||
let text = handle.get_string(&doc.transact());
|
||||
text
|
||||
}
|
||||
|
||||
fn is_text_path(path: &str) -> bool {
|
||||
let lower = path.to_lowercase();
|
||||
[".typ", ".toml", ".bib", ".csl", ".yml", ".yaml", ".json", ".md", ".txt", ".csv"]
|
||||
.iter()
|
||||
.any(|ext| lower.ends_with(ext))
|
||||
}
|
||||
|
||||
fn default_manifest(name: &str) -> String {
|
||||
format!(
|
||||
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nentrypoint = \"main.typ\"\nauthors = [\"Anonymous\"]\nlicense = \"MIT\"\ndescription = \"\"\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn slugify(name: &str) -> String {
|
||||
let slug: String = name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let trimmed = slug.trim_matches('-').replace("--", "-");
|
||||
if trimmed.is_empty() {
|
||||
"my-space".to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn space_role(
|
||||
state: &AppState,
|
||||
space_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 = ?"
|
||||
)
|
||||
.bind(space_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 let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT role FROM space_collaborators WHERE space_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(space_id)
|
||||
.bind(uid)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
return Some((space, role));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pr) = space.public_role.clone() {
|
||||
if pr == "viewer" || pr == "editor" {
|
||||
return Some((space, pr));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn load_local_packages(state: &AppState) -> HashMap<String, HashMap<String, Vec<u8>>> {
|
||||
let mut packages: HashMap<String, HashMap<String, Vec<u8>>> = HashMap::new();
|
||||
|
||||
let rows = sqlx::query_as::<_, (String, String, String, Vec<u8>)>(
|
||||
"SELECT p.name, v.version, f.path, f.data \
|
||||
FROM package_files f \
|
||||
JOIN package_versions v ON v.id = f.version_id \
|
||||
JOIN packages p ON p.id = v.package_id",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for (name, version, path, data) in rows {
|
||||
let key = format!("{}:{}", name, version);
|
||||
packages.entry(key).or_default().insert(path, data);
|
||||
}
|
||||
|
||||
packages
|
||||
}
|
||||
|
||||
pub async fn assemble_project(
|
||||
state: &AppState,
|
||||
space: &Space,
|
||||
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.
|
||||
if let Ok(account_files) = sqlx::query_as::<_, (String, Vec<u8>)>(
|
||||
"SELECT name, data FROM files WHERE owner_id = ?",
|
||||
)
|
||||
.bind(&space.owner_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
{
|
||||
for (name, data) in account_files {
|
||||
files.insert(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
|
||||
"SELECT path, kind, content FROM space_files WHERE space_id = ?",
|
||||
)
|
||||
.bind(&space.id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for (path, kind, content) in rows {
|
||||
if let Some(live) = overrides.get(&path) {
|
||||
files.insert(path, live.clone().into_bytes());
|
||||
} else if kind == "binary" {
|
||||
files.insert(path, content.unwrap_or_default());
|
||||
} else {
|
||||
files.insert(path, decode_text_blob(&content.unwrap_or_default()).into_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
for (path, content) in overrides {
|
||||
files.entry(path).or_insert_with(|| content.into_bytes());
|
||||
}
|
||||
|
||||
ProjectInput {
|
||||
entrypoint: space.entrypoint.clone(),
|
||||
files,
|
||||
packages: load_local_packages(state).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ListSpacesQuery {
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_spaces(
|
||||
Query(query): Query<ListSpacesQuery>,
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<Space>>, (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"
|
||||
)
|
||||
.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"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
}
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(spaces))
|
||||
}
|
||||
|
||||
pub async fn list_shared_spaces(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<Space>>, (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"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(spaces))
|
||||
}
|
||||
|
||||
pub async fn create_space(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateSpaceRequest>,
|
||||
) -> Result<Json<Space>, (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 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"
|
||||
)
|
||||
.bind(&space_id)
|
||||
.bind(&user_id)
|
||||
.bind(&payload.folder_id)
|
||||
.bind(&payload.name)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let seeds = [
|
||||
("typst.toml", default_manifest(&slugify(&payload.name))),
|
||||
("main.typ", "= New Space\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')"
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&space_id)
|
||||
.bind(path)
|
||||
.bind(encode_text_blob(&content))
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(Json(space))
|
||||
}
|
||||
|
||||
pub async fn get_space(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Space>, (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)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
|
||||
space.effective_role = Some(role);
|
||||
Ok(Json(space))
|
||||
}
|
||||
|
||||
pub async fn update_space(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateSpaceRequest>,
|
||||
) -> Result<Json<Space>, (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 = ?"
|
||||
)
|
||||
.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()))?;
|
||||
|
||||
if let Some(name) = payload.name {
|
||||
space.name = name;
|
||||
}
|
||||
if let Some(entrypoint) = payload.entrypoint {
|
||||
space.entrypoint = entrypoint;
|
||||
}
|
||||
if let Some(folder_id) = payload.folder_id {
|
||||
space.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() {
|
||||
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"
|
||||
)
|
||||
.bind(&space.name)
|
||||
.bind(&space.entrypoint)
|
||||
.bind(&space.folder_id)
|
||||
.bind(&space.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))
|
||||
}
|
||||
|
||||
pub async fn delete_space(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<StatusCode, (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 _ = sqlx::query("DELETE FROM space_files WHERE space_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
let result = sqlx::query("DELETE FROM spaces WHERE id = ? AND owner_id = ?")
|
||||
.bind(&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 or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_space_files(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<SpaceFile>>, (StatusCode, String)> {
|
||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
space_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"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
pub async fn create_space_file(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateSpaceFileRequest>,
|
||||
) -> Result<Json<SpaceFile>, (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)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
if role == "viewer" {
|
||||
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
|
||||
}
|
||||
|
||||
let kind = payload.kind.unwrap_or_else(|| "text".to_string());
|
||||
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"
|
||||
)
|
||||
.bind(&file_id)
|
||||
.bind(&id)
|
||||
.bind(&payload.path)
|
||||
.bind(&kind)
|
||||
.bind(encode_text_blob(&content))
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(file))
|
||||
}
|
||||
|
||||
pub async fn upload_space_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)
|
||||
.await
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||
if role == "viewer" {
|
||||
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
|
||||
}
|
||||
|
||||
let mut uploaded = vec![];
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
||||
let path = field.file_name().unwrap_or("unnamed").to_string();
|
||||
let mime_type = field.content_type().unwrap_or("application/octet-stream").to_string();
|
||||
let data = field.bytes().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?.to_vec();
|
||||
|
||||
let (kind, content) = if is_text_path(&path) {
|
||||
let text = String::from_utf8_lossy(&data).to_string();
|
||||
("text", encode_text_blob(&text))
|
||||
} else {
|
||||
("binary", data)
|
||||
};
|
||||
|
||||
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"
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&id)
|
||||
.bind(&path)
|
||||
.bind(kind)
|
||||
.bind(content)
|
||||
.bind(&mime_type)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
uploaded.push(path);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "files": uploaded })))
|
||||
}
|
||||
|
||||
pub async fn get_space_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)
|
||||
.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 = ?"
|
||||
)
|
||||
.bind(&file_id)
|
||||
.bind(&id)
|
||||
.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, mime_type, content) = file;
|
||||
let bytes = content.unwrap_or_default();
|
||||
|
||||
if kind == "binary" {
|
||||
Ok(([(header::CONTENT_TYPE, mime_type)], bytes))
|
||||
} else {
|
||||
Ok(([(header::CONTENT_TYPE, "text/plain".to_string())], decode_text_blob(&bytes).into_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_space_file(
|
||||
State(state): State<AppState>,
|
||||
Path((id, file_id)): Path<(String, String)>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateSpaceFileRequest>,
|
||||
) -> 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)
|
||||
.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 = ?")
|
||||
.bind(&payload.path)
|
||||
.bind(&file_id)
|
||||
.bind(&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, "File not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn delete_space_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)
|
||||
.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 = ?")
|
||||
.bind(&file_id)
|
||||
.bind(&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, "File not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
+48
-38
@@ -13,20 +13,29 @@ use typst_kit::packages::SystemPackages;
|
||||
pub struct MemoryWorld {
|
||||
library: typst::utils::LazyHash<Library>,
|
||||
main: FileId,
|
||||
source: Source,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
local_packages: HashMap<String, HashMap<String, Vec<u8>>>,
|
||||
book: typst::utils::LazyHash<FontBook>,
|
||||
fonts: Vec<Font>,
|
||||
packages: SystemPackages,
|
||||
}
|
||||
|
||||
const LOCAL_NAMESPACE: &str = "typstdrive";
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
path.trim_start_matches('/').replace('\\', "/")
|
||||
}
|
||||
|
||||
impl MemoryWorld {
|
||||
pub fn new(text: String, files: HashMap<String, Vec<u8>>) -> Self {
|
||||
pub fn new_project(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
local_packages: HashMap<String, HashMap<String, Vec<u8>>>,
|
||||
) -> Self {
|
||||
let main = FileId::new(RootedPath::new(
|
||||
VirtualRoot::Project,
|
||||
VirtualPath::new("main.typ").unwrap(),
|
||||
VirtualPath::new(&entrypoint).unwrap_or_else(|_| VirtualPath::new("main.typ").unwrap()),
|
||||
));
|
||||
let source = Source::new(main, text);
|
||||
let downloader = SystemDownloader::new("TypstDrive (typst-kit)");
|
||||
let packages = SystemPackages::new(downloader);
|
||||
|
||||
@@ -56,13 +65,40 @@ impl MemoryWorld {
|
||||
Self {
|
||||
library: typst::utils::LazyHash::new(Library::builder().build()),
|
||||
main,
|
||||
source,
|
||||
files,
|
||||
local_packages,
|
||||
book: typst::utils::LazyHash::new(book),
|
||||
fonts,
|
||||
packages,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
|
||||
let path = normalize_path(id.vpath().get_without_slash());
|
||||
|
||||
if let VirtualRoot::Package(package) = id.root() {
|
||||
if package.namespace.as_str() == LOCAL_NAMESPACE {
|
||||
let key = format!("{}:{}", package.name, package.version);
|
||||
return self
|
||||
.local_packages
|
||||
.get(&key)
|
||||
.and_then(|files| files.get(&path))
|
||||
.cloned()
|
||||
.ok_or_else(|| FileError::NotFound(path.clone().into()));
|
||||
}
|
||||
|
||||
let root = self
|
||||
.packages
|
||||
.obtain(package)
|
||||
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
|
||||
return root.load(id.vpath()).map(|bytes| bytes.to_vec());
|
||||
}
|
||||
|
||||
self.files
|
||||
.get(&path)
|
||||
.cloned()
|
||||
.ok_or_else(|| FileError::NotFound(path.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl World for MemoryWorld {
|
||||
@@ -79,42 +115,16 @@ impl World for MemoryWorld {
|
||||
}
|
||||
|
||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||
if id == self.main {
|
||||
Ok(self.source.clone())
|
||||
} else if let VirtualRoot::Package(package) = id.root() {
|
||||
let root = self
|
||||
.packages
|
||||
.obtain(package)
|
||||
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
|
||||
let data = root.load(id.vpath())?;
|
||||
let text = std::str::from_utf8(&data)
|
||||
.map_err(|_| FileError::InvalidUtf8)?
|
||||
.to_owned();
|
||||
Ok(Source::new(id, text))
|
||||
} else {
|
||||
Err(FileError::NotFound(id.vpath().get_without_slash().into()))
|
||||
}
|
||||
let data = self.load_bytes(id)?;
|
||||
let text = std::str::from_utf8(&data)
|
||||
.map_err(|_| FileError::InvalidUtf8)?
|
||||
.to_owned();
|
||||
Ok(Source::new(id, text))
|
||||
}
|
||||
|
||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||
if id == self.main {
|
||||
Ok(Bytes::from_string(self.source.text().to_string()))
|
||||
} else if let VirtualRoot::Package(package) = id.root() {
|
||||
let root = self
|
||||
.packages
|
||||
.obtain(package)
|
||||
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
|
||||
root.load(id.vpath())
|
||||
} else if let Some(data) = self.files.get(
|
||||
&id.vpath()
|
||||
.get_without_slash()
|
||||
.to_string()
|
||||
.replace("\\", "/"),
|
||||
) {
|
||||
Ok(Bytes::new(data.clone()))
|
||||
} else {
|
||||
Err(FileError::NotFound(id.vpath().get_without_slash().into()))
|
||||
}
|
||||
let data = self.load_bytes(id)?;
|
||||
Ok(Bytes::new(data))
|
||||
}
|
||||
|
||||
fn font(&self, index: usize) -> Option<Font> {
|
||||
|
||||
Reference in New Issue
Block a user