Initial Commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["ws", "multipart", "macros"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie", "cookie-private", "cookie-signed"] }
|
||||
tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
|
||||
tower = "0.5"
|
||||
argon2 = "0.5"
|
||||
futures-util = "0.3"
|
||||
ecow = "0.2"
|
||||
|
||||
typst = { version = "0.14.2", path = "../typst/crates/typst" }
|
||||
typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
|
||||
typst-layout = { path = "../typst/crates/typst-layout" }
|
||||
typst-pdf = { path = "../typst/crates/typst-pdf" }
|
||||
typst-render = { path = "../typst/crates/typst-render" }
|
||||
typst-svg = { path = "../typst/crates/typst-svg" }
|
||||
|
||||
yrs = "0.18.8"
|
||||
yrs-axum = "0.8"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::{User, RegisterRequest, LoginRequest, ChangePasswordRequest, UpdateProfileRequest},
|
||||
AppState,
|
||||
};
|
||||
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<Json<User>, (StatusCode, String)> {
|
||||
if payload.username.is_empty() || payload.password.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Username and password cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(payload.password.as_bytes(), &salt)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
let user_id = Uuid::new_v4().to_string();
|
||||
|
||||
let result = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?) RETURNING id, username, password_hash"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&payload.username)
|
||||
.bind(&password_hash)
|
||||
.fetch_one(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(user) => Ok(Json(user)),
|
||||
Err(sqlx::Error::Database(err)) if err.is_unique_violation() => {
|
||||
Err((StatusCode::CONFLICT, "Username already exists".to_string()))
|
||||
}
|
||||
Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<(SignedCookieJar, Json<User>), (StatusCode, String)> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE username = ?")
|
||||
.bind(&payload.username)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => return Err((StatusCode::UNAUTHORIZED, "Invalid username or password".to_string())),
|
||||
};
|
||||
|
||||
let parsed_hash = PasswordHash::new(&user.password_hash)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if Argon2::default().verify_password(payload.password.as_bytes(), &parsed_hash).is_err() {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid username or password".to_string()));
|
||||
}
|
||||
|
||||
let mut cookie = Cookie::new("session_user_id", user.id.clone());
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_same_site(SameSite::Lax);
|
||||
cookie.set_path("/");
|
||||
|
||||
let jar = jar.add(cookie);
|
||||
|
||||
Ok((jar, Json(user)))
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<User>, (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()))?;
|
||||
|
||||
if payload.username.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Username cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query("UPDATE users SET username = ? WHERE id = ?")
|
||||
.bind(&payload.username)
|
||||
.bind(&user_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?")
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
Ok(Json(user))
|
||||
}
|
||||
Err(sqlx::Error::Database(err)) if err.is_unique_violation() => {
|
||||
Err((StatusCode::CONFLICT, "Username already exists".to_string()))
|
||||
}
|
||||
Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(jar: SignedCookieJar) -> Result<(SignedCookieJar, StatusCode), (StatusCode, String)> {
|
||||
let jar = jar.remove(Cookie::from("session_user_id"));
|
||||
Ok((jar, StatusCode::OK))
|
||||
}
|
||||
|
||||
pub async fn me(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<User>, (StatusCode, String)> {
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
|
||||
let user_id = match user_id {
|
||||
Some(id) => id,
|
||||
None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())),
|
||||
};
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?")
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
match user {
|
||||
Some(u) => Ok(Json(u)),
|
||||
None => Err((StatusCode::UNAUTHORIZED, "User not found".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<ChangePasswordRequest>,
|
||||
) -> 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()))?;
|
||||
|
||||
if payload.current_password.is_empty() || payload.new_password.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?")
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||
|
||||
let parsed_hash = PasswordHash::new(&user.password_hash)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if Argon2::default().verify_password(payload.current_password.as_bytes(), &parsed_hash).is_err() {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid current password".to_string()));
|
||||
}
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let new_password_hash = Argon2::default()
|
||||
.hash_password(payload.new_password.as_bytes(), &salt)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(&new_password_hash)
|
||||
.bind(&user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::world::MemoryWorld;
|
||||
use std::collections::HashMap;
|
||||
use typst::diag::{SourceDiagnostic, Warned};
|
||||
use typst_layout::PagedDocument;
|
||||
use typst_pdf::{pdf, PdfOptions};
|
||||
use typst_render::render;
|
||||
|
||||
pub struct TypstCompiler;
|
||||
|
||||
impl TypstCompiler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn compile_svg(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<(Vec<String>, String), Vec<SourceDiagnostic>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
warnings: _,
|
||||
} => {
|
||||
let svgs = doc
|
||||
.pages()
|
||||
.iter()
|
||||
.map(|page| typst_svg::svg(page))
|
||||
.collect();
|
||||
let thumbnail = if let Some(page) = doc.pages().first() {
|
||||
typst_svg::svg(page)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok((svgs, thumbnail))
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => {
|
||||
let diag = errors.into_iter().collect();
|
||||
Err(diag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_pdf(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
warnings: _,
|
||||
} => {
|
||||
let opts = PdfOptions::default();
|
||||
match pdf(&doc, &opts) {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(_) => Err(vec![]),
|
||||
}
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(errors.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_png(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
warnings: _,
|
||||
} => {
|
||||
if let Some(page) = doc.pages().first() {
|
||||
let pixmap = render(page, 2.0);
|
||||
if let Ok(encoded) = pixmap.encode_png() {
|
||||
return Ok(encoded);
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(errors.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
|
||||
pub async fn init_db() -> Pool<Sqlite> {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect("sqlite:typstdrive.db?mode=rwc")
|
||||
.await
|
||||
.expect("Failed to create pool.");
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id),
|
||||
FOREIGN KEY(parent_id) REFERENCES folders(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
folder_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
content BLOB,
|
||||
thumbnail_svg TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id),
|
||||
FOREIGN KEY(folder_id) REFERENCES folders(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
document_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id),
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Failed to initialize database schema");
|
||||
|
||||
|
||||
let _ = sqlx::query("ALTER TABLE documents ADD COLUMN folder_id TEXT REFERENCES folders(id)")
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
|
||||
let _ = sqlx::query("ALTER TABLE documents ADD COLUMN thumbnail_svg TEXT")
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
pool
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Multipart},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::SignedCookieJar;
|
||||
use uuid::Uuid;
|
||||
use yrs::{Doc, ReadTxn, Transact, Text};
|
||||
|
||||
use crate::{
|
||||
models::{Document, CreateDocumentRequest},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ListDocsQuery {
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_documents(
|
||||
axum::extract::Query(query): axum::extract::Query<ListDocsQuery>,
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<Document>>, (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 docs = if let Some(folder_id) = query.folder_id {
|
||||
sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&folder_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
} else {
|
||||
sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
};
|
||||
|
||||
Ok(Json(docs))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn create_document(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateDocumentRequest>,
|
||||
) -> Result<Json<Document>, (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 doc_id = Uuid::new_v4().to_string();
|
||||
|
||||
let content = {
|
||||
let ydoc = Doc::new();
|
||||
let text = ydoc.get_or_insert_text("typst");
|
||||
let initial_text = payload.content.clone().unwrap_or_else(|| "== New Document".to_string());
|
||||
println!("Creating document with content length: {}", initial_text.len());
|
||||
text.insert(&mut ydoc.transact_mut(), 0, &initial_text);
|
||||
let encoded = ydoc.transact().encode_state_as_update_v1(&yrs::StateVector::default());
|
||||
println!("Encoded Yjs state length: {}", encoded.len());
|
||||
encoded
|
||||
};
|
||||
|
||||
let doc = sqlx::query_as::<_, Document>(
|
||||
"INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES (?, ?, ?, ?, ?) RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at"
|
||||
)
|
||||
.bind(&doc_id)
|
||||
.bind(&user_id)
|
||||
.bind(&payload.folder_id)
|
||||
.bind(&payload.title)
|
||||
.bind(&content)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(doc))
|
||||
}
|
||||
|
||||
pub async fn get_document(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Document>, (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 doc = sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents 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()))?;
|
||||
|
||||
match doc {
|
||||
Some(d) => Ok(Json(d)),
|
||||
None => Err((StatusCode::NOT_FOUND, "Document not found".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_document(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<crate::models::UpdateDocumentRequest>,
|
||||
) -> Result<Json<Document>, (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 doc = sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents 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, "Document not found".to_string()))?;
|
||||
|
||||
if let Some(new_title) = payload.title {
|
||||
doc.title = new_title;
|
||||
}
|
||||
if let Some(new_folder_id) = payload.folder_id {
|
||||
if new_folder_id.is_empty() {
|
||||
doc.folder_id = None;
|
||||
} else {
|
||||
doc.folder_id = Some(new_folder_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let doc = sqlx::query_as::<_, Document>(
|
||||
"UPDATE documents SET title = ?, folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at"
|
||||
)
|
||||
.bind(&doc.title)
|
||||
.bind(&doc.folder_id)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(doc))
|
||||
}
|
||||
|
||||
pub async fn delete_document(
|
||||
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 result = sqlx::query("DELETE FROM documents 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, "Document not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn upload_file(
|
||||
State(state): State<AppState>,
|
||||
Path(doc_id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (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 doc_exists = sqlx::query_as::<_, (String, Option<String>)>("SELECT id, folder_id FROM documents WHERE id = ? AND owner_id = ?")
|
||||
.bind(&doc_id)
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if doc_exists.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "Document not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
let (_, folder_id) = doc_exists.unwrap();
|
||||
|
||||
let mut uploaded_filename = String::new();
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
||||
let file_name = field.file_name().unwrap_or("unnamed").to_string();
|
||||
let content_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 file_id = Uuid::new_v4().to_string();
|
||||
|
||||
sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.bind(&file_id)
|
||||
.bind(&user_id)
|
||||
.bind(&doc_id)
|
||||
.bind(&folder_id)
|
||||
.bind(&file_name)
|
||||
.bind(&content_type)
|
||||
.bind(&data)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
uploaded_filename = file_name;
|
||||
break;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"filename": uploaded_filename})))
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query, Multipart},
|
||||
http::{StatusCode, header},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::SignedCookieJar;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::{File},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListFilesQuery {
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_files(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Query(query): Query<ListFilesQuery>,
|
||||
) -> Result<Json<Vec<File>>, (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 files = if let Some(folder_id) = query.folder_id {
|
||||
sqlx::query_as::<_, File>(
|
||||
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = ? AND folder_id = ? ORDER BY name ASC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&folder_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
} else {
|
||||
sqlx::query_as::<_, File>(
|
||||
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = ? AND folder_id IS NULL ORDER BY name ASC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
};
|
||||
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UploadFileQuery {
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn upload_file_global(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Query(query): Query<UploadFileQuery>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (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 uploaded_files = vec![];
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
||||
let file_name = field.file_name().unwrap_or("unnamed").to_string();
|
||||
let content_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 file_id = Uuid::new_v4().to_string();
|
||||
|
||||
sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(&file_id)
|
||||
.bind(&user_id)
|
||||
.bind(&query.folder_id)
|
||||
.bind(&file_name)
|
||||
.bind(&content_type)
|
||||
.bind(&data)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
uploaded_files.push(file_name);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"files": uploaded_files})))
|
||||
}
|
||||
|
||||
pub async fn get_file_data(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<impl IntoResponse, (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 file = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT mime_type, data FROM files 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()))?;
|
||||
|
||||
if let Some((mime_type, data)) = file {
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, mime_type)],
|
||||
data,
|
||||
))
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "File not found".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_file(
|
||||
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 result = sqlx::query("DELETE FROM files 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, "File not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateFileRequest {
|
||||
pub name: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_file(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateFileRequest>,
|
||||
) -> Result<Json<File>, (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 file = sqlx::query_as::<_, File>(
|
||||
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files 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, "File not found".to_string()))?;
|
||||
|
||||
if let Some(new_name) = payload.name {
|
||||
file.name = new_name;
|
||||
}
|
||||
if let Some(new_folder_id) = payload.folder_id {
|
||||
if new_folder_id.is_empty() {
|
||||
file.folder_id = None;
|
||||
} else {
|
||||
file.folder_id = Some(new_folder_id);
|
||||
}
|
||||
}
|
||||
|
||||
let file = sqlx::query_as::<_, File>(
|
||||
"UPDATE files SET name = ?, folder_id = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, document_id, folder_id, name, mime_type, created_at"
|
||||
)
|
||||
.bind(&file.name)
|
||||
.bind(&file.folder_id)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(file))
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::SignedCookieJar;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::{Folder, CreateFolderRequest},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListFoldersQuery {
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_folders(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Query(query): Query<ListFoldersQuery>,
|
||||
) -> Result<Json<Vec<Folder>>, (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 folders = if let Some(parent_id) = query.parent_id {
|
||||
sqlx::query_as::<_, Folder>(
|
||||
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id = ? ORDER BY name ASC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&parent_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
} else {
|
||||
sqlx::query_as::<_, Folder>(
|
||||
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id IS NULL ORDER BY name ASC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
};
|
||||
|
||||
Ok(Json(folders))
|
||||
}
|
||||
|
||||
pub async fn create_folder(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateFolderRequest>,
|
||||
) -> Result<Json<Folder>, (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 folder_id = Uuid::new_v4().to_string();
|
||||
|
||||
let folder = sqlx::query_as::<_, Folder>(
|
||||
"INSERT INTO folders (id, owner_id, parent_id, name) VALUES (?, ?, ?, ?) RETURNING id, owner_id, parent_id, name, created_at"
|
||||
)
|
||||
.bind(&folder_id)
|
||||
.bind(&user_id)
|
||||
.bind(&payload.parent_id)
|
||||
.bind(&payload.name)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(folder))
|
||||
}
|
||||
|
||||
pub async fn delete_folder(
|
||||
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 result = sqlx::query("DELETE FROM folders 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, "Folder not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateFolderRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn update_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
) -> Result<Json<Folder>, (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 folder = sqlx::query_as::<_, Folder>(
|
||||
"UPDATE folders SET name = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, parent_id, name, created_at"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
match folder {
|
||||
Some(f) => Ok(Json(f)),
|
||||
None => Err((StatusCode::NOT_FOUND, "Folder not found".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use yrs_axum::ws::{AxumSink, AxumStream};
|
||||
use yrs_axum::broadcast::BroadcastGroup;
|
||||
use yrs::sync::Awareness;
|
||||
use yrs::{Doc, ReadTxn, Transact, Update};
|
||||
use yrs::updates::decoder::Decode;
|
||||
use futures_util::stream::StreamExt;
|
||||
use crate::AppState;
|
||||
use crate::models::Document;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CompileRequest {
|
||||
pub text: String,
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CompileResponse {
|
||||
pub svgs: Option<Vec<String>>,
|
||||
pub errors: Option<Vec<Diagnostic>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Diagnostic {
|
||||
pub message: String,
|
||||
pub severity: String,
|
||||
}
|
||||
|
||||
pub async fn yjs_handler(
|
||||
ws: axum::extract::ws::WebSocketUpgrade,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let mut bcast_map = state.bcast_map.lock().await;
|
||||
let bcast = if let Some(bcast) = bcast_map.get(&id) {
|
||||
bcast.clone()
|
||||
} else {
|
||||
let doc = sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let ydoc = Doc::new();
|
||||
|
||||
if let Ok(Some(db_doc)) = doc {
|
||||
if let Some(content) = db_doc.content {
|
||||
if let Ok(update) = Update::decode_v1(&content) {
|
||||
ydoc.transact_mut().apply_update(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let awareness = Arc::new(RwLock::new(Awareness::new(ydoc)));
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
new_bcast
|
||||
};
|
||||
|
||||
drop(bcast_map);
|
||||
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
let (sink, stream) = socket.split();
|
||||
let sink = Arc::new(Mutex::new(AxumSink(sink)));
|
||||
let stream = AxumStream(stream);
|
||||
let sub = bcast.subscribe(sink, stream);
|
||||
match sub.completed().await {
|
||||
Ok(_) => println!("broadcasting for channel finished successfully"),
|
||||
Err(e) => eprintln!("broadcasting for channel finished abruptly: {}", e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn compile_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CompileRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let mut files_map = std::collections::HashMap::new();
|
||||
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, created_at, updated_at FROM documents WHERE id = ?").bind(doc_id).fetch_one(&state.db).await {
|
||||
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
||||
.bind(doc.owner_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
{
|
||||
for (name, data) in files {
|
||||
files_map.insert(name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let compiler = state.compiler.lock().await;
|
||||
match compiler.compile_svg(payload.text, files_map) {
|
||||
Ok((svgs, thumbnail)) => {
|
||||
if let Some(doc_id) = &payload.document_id {
|
||||
let _ = sqlx::query("UPDATE documents SET thumbnail_svg = ? WHERE id = ?")
|
||||
.bind(&thumbnail)
|
||||
.bind(doc_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
|
||||
Json(CompileResponse {
|
||||
svgs: Some(svgs),
|
||||
errors: None,
|
||||
})
|
||||
}
|
||||
Err(diags) => {
|
||||
let errors = diags
|
||||
.into_iter()
|
||||
.map(|d| Diagnostic {
|
||||
message: d.message.to_string(),
|
||||
severity: format!("{:?}", d.severity),
|
||||
})
|
||||
.collect();
|
||||
Json(CompileResponse {
|
||||
svgs: None,
|
||||
errors: Some(errors),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn export_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(format): Path<String>,
|
||||
Json(payload): Json<CompileRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let mut files_map = std::collections::HashMap::new();
|
||||
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, created_at, updated_at FROM documents WHERE id = ?").bind(doc_id).fetch_one(&state.db).await {
|
||||
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
||||
.bind(doc.owner_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
{
|
||||
for (name, data) in files {
|
||||
files_map.insert(name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let compiler = state.compiler.lock().await;
|
||||
|
||||
match format.as_str() {
|
||||
"pdf" => match compiler.export_pdf(payload.text, files_map.clone()) {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/pdf")],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
|
||||
},
|
||||
"png" => match compiler.export_png(payload.text, files_map.clone()) {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "image/png")],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
|
||||
},
|
||||
"svg" => match compiler.compile_svg(payload.text, files_map.clone()) {
|
||||
Ok((svgs, _)) => {
|
||||
|
||||
|
||||
let mut combined = String::new();
|
||||
for svg in svgs {
|
||||
combined.push_str(&svg);
|
||||
combined.push_str("\n");
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "image/svg+xml")],
|
||||
combined.into_bytes(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
|
||||
},
|
||||
_ => (StatusCode::NOT_FOUND, "Format not supported").into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use axum::{
|
||||
routing::{get, post, put, delete},
|
||||
Router,
|
||||
};
|
||||
use axum_extra::extract::cookie::Key;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
use yrs_axum::broadcast::BroadcastGroup;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod auth;
|
||||
mod compiler;
|
||||
mod db;
|
||||
mod docs;
|
||||
mod folders;
|
||||
mod files;
|
||||
mod handlers;
|
||||
mod models;
|
||||
mod world;
|
||||
|
||||
use compiler::TypstCompiler;
|
||||
use handlers::{compile_handler, export_handler, yjs_handler};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub compiler: Arc<Mutex<TypstCompiler>>,
|
||||
pub bcast_map: Arc<Mutex<HashMap<String, Arc<BroadcastGroup>>>>,
|
||||
pub db: Pool<Sqlite>,
|
||||
pub key: Key,
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<AppState> for Key {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.key.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "server=debug,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
tracing::info!("Starting TypstDrive Server");
|
||||
|
||||
let db = db::init_db().await;
|
||||
|
||||
|
||||
let key = Key::generate();
|
||||
|
||||
let state = AppState {
|
||||
compiler: Arc::new(Mutex::new(TypstCompiler::new())),
|
||||
bcast_map: Arc::new(Mutex::new(HashMap::new())),
|
||||
db,
|
||||
key,
|
||||
};
|
||||
|
||||
let api_routes = Router::new()
|
||||
.route("/compile", post(compile_handler))
|
||||
.route("/export/{format}", post(export_handler))
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/me", get(auth::me).put(auth::update_profile))
|
||||
.route("/auth/change-password", put(auth::change_password))
|
||||
.route("/folders", get(folders::list_folders).post(folders::create_folder))
|
||||
.route("/folders/{id}", delete(folders::delete_folder).patch(folders::update_folder))
|
||||
.route("/files", get(files::list_files).post(files::upload_file_global))
|
||||
.route("/files/{id}", delete(files::delete_file).patch(files::update_file))
|
||||
.route("/files/{id}/data", get(files::get_file_data))
|
||||
.route("/docs", get(docs::list_documents).post(docs::create_document))
|
||||
.route("/docs/{id}", get(docs::get_document).delete(docs::delete_document).patch(docs::update_document))
|
||||
.route("/docs/{id}/files", post(docs::upload_file));
|
||||
|
||||
let yjs_routes = Router::new()
|
||||
.route("/{id}", get(yjs_handler));
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", api_routes.layer(TraceLayer::new_for_http()))
|
||||
.nest("/yjs", yjs_routes.layer(TraceLayer::new_for_http()))
|
||||
.fallback_service(ServeDir::new("../build").fallback(ServeFile::new("../build/index.html")))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
||||
.await
|
||||
.unwrap();
|
||||
tracing::info!("Server listening on http://0.0.0.0:3000");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Folder {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub name: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct File {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub document_id: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub name: String,
|
||||
pub mime_type: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Document {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub content: Option<Vec<u8>>,
|
||||
pub thumbnail_svg: Option<String>,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateFolderRequest {
|
||||
pub name: String,
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateDocumentRequest {
|
||||
pub title: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateDocumentRequest {
|
||||
pub title: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateFileRequest {
|
||||
pub name: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use chrono::Datelike;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use typst::diag::{FileError, FileResult};
|
||||
use typst::foundations::{Bytes, Datetime, Duration};
|
||||
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
|
||||
use typst::text::{Font, FontBook};
|
||||
use typst::World;
|
||||
use typst::{Library, LibraryExt};
|
||||
use typst_kit::downloader::SystemDownloader;
|
||||
use typst_kit::fonts::FontStore;
|
||||
use typst_kit::packages::SystemPackages;
|
||||
|
||||
pub struct MemoryWorld {
|
||||
library: typst::utils::LazyHash<Library>,
|
||||
main: FileId,
|
||||
source: Source,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
fonts: std::sync::LazyLock<FontStore, Box<dyn Fn() -> FontStore + Send + Sync>>,
|
||||
packages: SystemPackages,
|
||||
}
|
||||
|
||||
impl MemoryWorld {
|
||||
pub fn new(text: String, files: HashMap<String, Vec<u8>>) -> Self {
|
||||
let main = FileId::new(RootedPath::new(
|
||||
VirtualRoot::Project,
|
||||
VirtualPath::new("main.typ").unwrap(),
|
||||
));
|
||||
let source = Source::new(main, text);
|
||||
let files_clone = files.clone();
|
||||
let downloader = SystemDownloader::new("TypstDrive (typst-kit)");
|
||||
let packages = SystemPackages::new(downloader);
|
||||
|
||||
Self {
|
||||
library: typst::utils::LazyHash::new(Library::builder().build()),
|
||||
main,
|
||||
source,
|
||||
fonts: std::sync::LazyLock::new(Box::new(move || {
|
||||
let mut store = FontStore::new();
|
||||
store.extend(typst_kit::fonts::embedded());
|
||||
|
||||
for (name, data) in &files {
|
||||
if name.ends_with(".ttf") || name.ends_with(".otf") {
|
||||
for font in Font::iter(Bytes::new(data.clone())) {
|
||||
let info = font.info().clone();
|
||||
store.push((font.clone(), info.clone()));
|
||||
|
||||
let mut custom_info = info;
|
||||
if let Some(stem) = std::path::Path::new(name).file_stem() {
|
||||
if let Some(stem_str) = stem.to_str() {
|
||||
custom_info.family = stem_str.to_string();
|
||||
store.push((font, custom_info));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store
|
||||
})),
|
||||
files: files_clone,
|
||||
packages,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl World for MemoryWorld {
|
||||
fn library(&self) -> &typst::utils::LazyHash<Library> {
|
||||
&self.library
|
||||
}
|
||||
|
||||
fn book(&self) -> &typst::utils::LazyHash<FontBook> {
|
||||
self.fonts.book()
|
||||
}
|
||||
|
||||
fn main(&self) -> FileId {
|
||||
self.main
|
||||
}
|
||||
|
||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||
if id == self.main {
|
||||
Ok(self.source.clone())
|
||||
} else if let typst::syntax::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 = String::from_utf8(data.to_vec()).map_err(|_| FileError::InvalidUtf8)?;
|
||||
Ok(Source::new(id, text))
|
||||
} else {
|
||||
Err(FileError::NotFound(
|
||||
std::path::Path::new(id.vpath().get_without_slash()).into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||
if id == self.main {
|
||||
Ok(Bytes::from_string(self.source.text().to_string()))
|
||||
} else if let typst::syntax::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()) {
|
||||
Ok(Bytes::new(data.clone()))
|
||||
} else {
|
||||
Err(FileError::NotFound(
|
||||
std::path::Path::new(id.vpath().get_without_slash()).into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn font(&self, index: usize) -> Option<Font> {
|
||||
self.fonts.font(index)
|
||||
}
|
||||
|
||||
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
|
||||
let now = chrono::Local::now();
|
||||
let date = if let Some(offset) = offset {
|
||||
let offset = chrono::FixedOffset::east_opt(offset.seconds() as i32)?;
|
||||
now.with_timezone(&offset).date_naive()
|
||||
} else {
|
||||
now.date_naive()
|
||||
};
|
||||
|
||||
Datetime::from_ymd(date.year(), date.month() as u8, date.day() as u8)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user