Initial Commit

This commit is contained in:
2026-04-04 23:22:30 -04:00
commit 246f7357a9
61 changed files with 4792 additions and 0 deletions
+32
View File
@@ -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"
+190
View File
@@ -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)
}
+97
View File
@@ -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()),
}
}
}
+67
View File
@@ -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
}
+227
View File
@@ -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})))
}
+186
View File
@@ -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))
}
+127
View 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())),
}
}
+213
View File
@@ -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(),
}
}
+98
View File
@@ -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();
}
+91
View File
@@ -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>,
}
+131
View File
@@ -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)
}
}