From 61251b3ea7e679d45691962492858474b8c3cead Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Thu, 14 May 2026 17:02:42 -0400 Subject: [PATCH] Updated Typst v0.14.2 and added SQLite database support --- Dockerfile | 5 +- docker-compose.yml | 32 ++++----- server/Cargo.toml | 2 +- server/src/auth.rs | 25 ++++--- server/src/collab.rs | 106 ++++++++++++++++-------------- server/src/db.rs | 106 ------------------------------ server/src/db/mod.rs | 36 ++++++++++ server/src/db/postgres.rs | 94 ++++++++++++++++++++++++++ server/src/db/sqlite.rs | 90 +++++++++++++++++++++++++ server/src/docs.rs | 39 +++++------ server/src/files.rs | 30 ++++----- server/src/folders.rs | 13 ++-- server/src/handlers.rs | 73 +++++++++++--------- server/src/main.rs | 4 +- server/src/models.rs | 20 +++--- server/src/world.rs | 17 ++--- src/lib/components/Toolbar.svelte | 6 +- typst | 2 +- 18 files changed, 410 insertions(+), 290 deletions(-) delete mode 100644 server/src/db.rs create mode 100644 server/src/db/mod.rs create mode 100644 server/src/db/postgres.rs create mode 100644 server/src/db/sqlite.rs diff --git a/Dockerfile b/Dockerfile index e348155..fb35931 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN bun run build FROM rust:alpine AS backend-builder WORKDIR /app RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig git -RUN git clone https://github.com/typst/typst.git typst && cd typst && git checkout d6848a802e86a6269300f9768c054a641c2da77f +RUN git clone --depth=1 https://github.com/typst/typst.git typst COPY server/Cargo.* server/ COPY server/src server/src WORKDIR /app/server @@ -19,7 +19,8 @@ RUN cargo build --release # Final Runtime Image FROM alpine:3.19 WORKDIR /app -RUN apk add --no-cache libgcc openssl pandoc curl +RUN apk add --no-cache libgcc openssl pandoc curl sqlite +RUN mkdir -p /data RUN curl -L https://github.com/Myriad-Dreamin/tinymist/releases/latest/download/tinymist-alpine-x64 -o /usr/local/bin/tinymist && chmod +x /usr/local/bin/tinymist COPY --from=frontend-builder /app/build /app/build COPY --from=backend-builder /app/server/target/release/server /app/server diff --git a/docker-compose.yml b/docker-compose.yml index bdb5163..b565f66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,25 @@ services: - db: - image: postgres:16-alpine - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: typstdrive - ports: - - "5433:5432" - volumes: - - pgdata:/var/lib/postgresql/data - app: build: . ports: - "3000:3000" environment: - - DATABASE_URL=postgres://postgres:password@db:5432/typstdrive - depends_on: - - db + - DATABASE_URL=sqlite:///data/typstdrive.db?mode=rwc + - DB_TYPE=sqlite # set to "postgres" with a postgres DATABASE_URL to use PostgreSQL + volumes: + - appdata:/data + + # Uncomment to use PostgreSQL instead of SQLite + # db: + # image: postgres:16-alpine + # environment: + # POSTGRES_USER: postgres + # POSTGRES_PASSWORD: password + # POSTGRES_DB: typstdrive + # ports: + # - "5433:5432" + # volumes: + # - appdata:/var/lib/postgresql/data volumes: - pgdata: + appdata: diff --git a/server/Cargo.toml b/server/Cargo.toml index a45d1db..f7fcbd5 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,7 +9,7 @@ axum-extra = { version = "0.10", features = ["cookie", "cookie-private", "cookie 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 = ["postgres", "runtime-tokio-rustls", "chrono", "uuid"] } +sqlx = { version = "0.8", features = ["postgres", "sqlite", "any", "runtime-tokio-rustls", "chrono", "uuid"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } diff --git a/server/src/auth.rs b/server/src/auth.rs index 6833006..9e9857e 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -34,7 +34,7 @@ pub async fn register( let user_id = Uuid::new_v4().to_string(); let result = sqlx::query_as::<_, User>( - "INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING id, username, email, password_hash" + "INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?) RETURNING id, username, email, password_hash" ) .bind(&user_id) .bind(&payload.username) @@ -57,7 +57,7 @@ pub async fn login( jar: SignedCookieJar, Json(payload): Json, ) -> Result<(SignedCookieJar, Json), (StatusCode, String)> { - let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE email = $1") + let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE email = ?") .bind(&payload.email) .fetch_optional(&state.db) .await @@ -79,7 +79,7 @@ pub async fn login( cookie.set_http_only(true); cookie.set_same_site(SameSite::Lax); cookie.set_path("/"); - + let jar = jar.add(cookie); Ok((jar, Json(user))) @@ -97,7 +97,7 @@ pub async fn update_profile( return Err((StatusCode::BAD_REQUEST, "Username and email cannot be empty".to_string())); } - let result = sqlx::query("UPDATE users SET username = $1, email = $2 WHERE id = $3") + let result = sqlx::query("UPDATE users SET username = ?, email = ? WHERE id = ?") .bind(&payload.username) .bind(&payload.email) .bind(&user_id) @@ -106,7 +106,7 @@ pub async fn update_profile( match result { Ok(_) => { - let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = $1") + let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?") .bind(&user_id) .fetch_optional(&state.db) .await @@ -130,14 +130,12 @@ pub async fn me( State(state): State, jar: SignedCookieJar, ) -> Result, (StatusCode, String)> { - let user_id = jar.get("session_user_id").map(|c| c.value().to_string()); - - let user_id = match user_id { + let user_id = match jar.get("session_user_id").map(|c| c.value().to_string()) { Some(id) => id, None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())), }; - let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = $1") + let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?") .bind(&user_id) .fetch_optional(&state.db) .await @@ -161,7 +159,7 @@ pub async fn change_password( return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string())); } - let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = $1") + let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?") .bind(&user_id) .fetch_optional(&state.db) .await @@ -181,7 +179,7 @@ pub async fn change_password( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .to_string(); - sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") + sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") .bind(&new_password_hash) .bind(&user_id) .execute(&state.db) @@ -198,8 +196,9 @@ pub async fn storage_stats( let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + // LENGTH() returns byte count for both BYTEA (Postgres) and BLOB (SQLite) let docs_size: (i64,) = sqlx::query_as( - "SELECT COALESCE(SUM(OCTET_LENGTH(content)), 0) FROM documents WHERE owner_id = $1" + "SELECT COALESCE(SUM(LENGTH(content)), 0) FROM documents WHERE owner_id = ?" ) .bind(&user_id) .fetch_one(&state.db) @@ -207,7 +206,7 @@ pub async fn storage_stats( .unwrap_or((0,)); let files_size: (i64,) = sqlx::query_as( - "SELECT COALESCE(SUM(OCTET_LENGTH(data)), 0) FROM files WHERE owner_id = $1" + "SELECT COALESCE(SUM(LENGTH(data)), 0) FROM files WHERE owner_id = ?" ) .bind(&user_id) .fetch_one(&state.db) diff --git a/server/src/collab.rs b/server/src/collab.rs index 73677e5..b6955c6 100644 --- a/server/src/collab.rs +++ b/server/src/collab.rs @@ -21,8 +21,7 @@ pub async fn invite_collaborator( let inviter_id = jar.get("session_user_id").map(|c| c.value().to_string()) .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; - // Check if the user is the owner - let doc_exists = sqlx::query_as::<_, (String,)>("SELECT id FROM documents WHERE id = $1 AND owner_id = $2") + let doc_exists = sqlx::query_as::<_, (String,)>("SELECT id FROM documents WHERE id = ? AND owner_id = ?") .bind(&doc_id) .bind(&inviter_id) .fetch_optional(&state.db) @@ -33,8 +32,7 @@ pub async fn invite_collaborator( return Err((StatusCode::FORBIDDEN, "Only the owner can invite collaborators".to_string())); } - // Find the user by email - let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash FROM users WHERE email = $1") + let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash FROM users WHERE email = ?") .bind(&payload.email) .fetch_optional(&state.db) .await @@ -43,7 +41,7 @@ pub async fn invite_collaborator( if let Some(user) = invited_user { let collab_id = Uuid::new_v4().to_string(); let _collab = sqlx::query_as::<_, Collaborator>( - "INSERT INTO collaborators (id, document_id, user_id, role) VALUES ($1, $2, $3, $4) ON CONFLICT (document_id, user_id) DO UPDATE SET role = EXCLUDED.role RETURNING id, document_id, user_id, role, created_at" + "INSERT INTO collaborators (id, document_id, user_id, role) VALUES (?, ?, ?, ?) ON CONFLICT (document_id, user_id) DO UPDATE SET role = excluded.role RETURNING id, document_id, user_id, role, created_at" ) .bind(&collab_id) .bind(&doc_id) @@ -52,14 +50,13 @@ pub async fn invite_collaborator( .fetch_one(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - // Mock returning an invitation so frontend knows it succeeded + let inv = Invitation { id: Uuid::new_v4().to_string(), document_id: doc_id.to_string(), role: payload.role.clone(), token: "direct-added".to_string(), - created_at: chrono::Utc::now().naive_utc(), + created_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), expires_at: None, }; Ok(Json(inv)) @@ -82,7 +79,7 @@ pub async fn accept_invite( .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; let invitation = sqlx::query_as::<_, Invitation>( - "SELECT id, document_id, role, token, created_at, expires_at FROM invitations WHERE token = $1" + "SELECT id, document_id, role, token, created_at, expires_at FROM invitations WHERE token = ?" ) .bind(&query.token) .fetch_optional(&state.db) @@ -93,7 +90,7 @@ pub async fn accept_invite( let collab_id = Uuid::new_v4().to_string(); let collab = sqlx::query_as::<_, Collaborator>( - "INSERT INTO collaborators (id, document_id, user_id, role) VALUES ($1, $2, $3, $4) ON CONFLICT (document_id, user_id) DO UPDATE SET role = EXCLUDED.role RETURNING id, document_id, user_id, role, created_at" + "INSERT INTO collaborators (id, document_id, user_id, role) VALUES (?, ?, ?, ?) ON CONFLICT (document_id, user_id) DO UPDATE SET role = excluded.role RETURNING id, document_id, user_id, role, created_at" ) .bind(&collab_id) .bind(&invitation.document_id) @@ -114,12 +111,11 @@ pub async fn get_comments( let _user_id = jar.get("session_user_id").map(|c| c.value().to_string()) .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; - // Basic access control omitted for brevity let comments = sqlx::query_as::<_, Comment>( "SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \ FROM comments c \ LEFT JOIN users u ON c.user_id = u.id \ - WHERE c.document_id = $1 \ + WHERE c.document_id = ? \ ORDER BY c.created_at ASC" ) .bind(&doc_id) @@ -141,20 +137,22 @@ pub async fn add_comment( let comment_id = Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO comments (id, document_id, user_id, content) VALUES (?, ?, ?, ?)") + .bind(&comment_id) + .bind(&doc_id) + .bind(&user_id) + .bind(&payload.content) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let comment = sqlx::query_as::<_, Comment>( - "WITH new_comment AS ( \ - INSERT INTO comments (id, document_id, user_id, content) \ - VALUES ($1, $2, $3, $4) \ - RETURNING id, document_id, user_id, content, resolved, created_at \ - ) \ - SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \ - FROM new_comment c \ - LEFT JOIN users u ON c.user_id = u.id" + "SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \ + FROM comments c \ + LEFT JOIN users u ON c.user_id = u.id \ + WHERE c.id = ?" ) .bind(&comment_id) - .bind(&doc_id) - .bind(&user_id) - .bind(&payload.content) .fetch_one(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -171,16 +169,17 @@ pub async fn create_version( let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; - // Check access - let doc = sqlx::query_as::<_, crate::models::Document>("SELECT * FROM documents WHERE id = $1") - .bind(&doc_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()))?; + let 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 = ?" + ) + .bind(&doc_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()))?; let is_owner = doc.owner_id == user_id; - let role = sqlx::query_scalar::<_, String>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2") + let role = sqlx::query_scalar::<_, String>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?") .bind(&doc_id) .bind(&user_id) .fetch_optional(&state.db) @@ -193,13 +192,22 @@ pub async fn create_version( let version_id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO document_versions (id, document_id, user_id, content) VALUES (?, ?, ?, ?)") + .bind(&version_id) + .bind(&doc_id) + .bind(&user_id) + .bind(&payload.content) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let version = sqlx::query_as::<_, crate::models::DocumentVersion>( - "INSERT INTO document_versions (id, document_id, user_id, content) VALUES ($1, $2, $3, $4) RETURNING *, (SELECT username FROM users WHERE id = $3) as author_name" + "SELECT v.id, v.document_id, v.user_id, v.content, v.created_at, u.username as author_name \ + FROM document_versions v \ + LEFT JOIN users u ON v.user_id = u.id \ + WHERE v.id = ?" ) .bind(&version_id) - .bind(&doc_id) - .bind(&user_id) - .bind(&payload.content) .fetch_one(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -215,12 +223,11 @@ pub async fn get_versions( let _user_id = jar.get("session_user_id").map(|c| c.value().to_string()) .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; - // Basic access check let versions = sqlx::query_as::<_, crate::models::DocumentVersion>( "SELECT v.id, v.document_id, v.user_id, v.content, v.created_at, u.username as author_name \ FROM document_versions v \ LEFT JOIN users u ON v.user_id = u.id \ - WHERE v.document_id = $1 \ + WHERE v.document_id = ? \ ORDER BY v.created_at DESC" ) .bind(&doc_id) @@ -244,7 +251,7 @@ pub async fn update_comment( "SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \ FROM comments c \ LEFT JOIN users u ON c.user_id = u.id \ - WHERE c.id = $1 AND c.user_id = $2" + WHERE c.id = ? AND c.user_id = ?" ) .bind(&comment_id) .bind(&user_id) @@ -260,17 +267,20 @@ pub async fn update_comment( comment.resolved = r; } + sqlx::query("UPDATE comments SET content = ?, resolved = ? WHERE id = ?") + .bind(&comment.content) + .bind(comment.resolved) + .bind(&comment.id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let updated_comment = sqlx::query_as::<_, Comment>( - "WITH updated_comment AS ( \ - UPDATE comments SET content = $1, resolved = $2 WHERE id = $3 \ - RETURNING id, document_id, user_id, content, resolved, created_at \ - ) \ - SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \ - FROM updated_comment c \ - LEFT JOIN users u ON c.user_id = u.id" + "SELECT c.id, c.document_id, c.user_id, c.content, c.resolved, c.created_at, u.username as author_name \ + FROM comments c \ + LEFT JOIN users u ON c.user_id = u.id \ + WHERE c.id = ?" ) - .bind(&comment.content) - .bind(comment.resolved) .bind(&comment.id) .fetch_one(&state.db) .await @@ -287,7 +297,7 @@ pub async fn delete_comment( 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 comments WHERE id = $1 AND user_id = $2") + let result = sqlx::query("DELETE FROM comments WHERE id = ? AND user_id = ?") .bind(&comment_id) .bind(&user_id) .execute(&state.db) diff --git a/server/src/db.rs b/server/src/db.rs deleted file mode 100644 index 509a59d..0000000 --- a/server/src/db.rs +++ /dev/null @@ -1,106 +0,0 @@ -use sqlx::postgres::PgPoolOptions; -use sqlx::{Pool, Postgres}; - -pub async fn init_db() -> Pool { - let db_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://postgres:password@192.168.1.214:5432/typstdrive".to_string()); - - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(&db_url) - .await - .expect("Failed to create Postgres pool. Make sure your database is running."); - - let schema = r#" -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - email TEXT UNIQUE, - password_hash TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS folders ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL REFERENCES users(id), - parent_id TEXT REFERENCES folders(id), - name TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -CREATE TABLE IF NOT EXISTS documents ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL REFERENCES users(id), - folder_id TEXT REFERENCES folders(id), - title TEXT NOT NULL, - content BYTEA, - thumbnail_svg TEXT, - public_role TEXT DEFAULT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -CREATE TABLE IF NOT EXISTS files ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL REFERENCES users(id), - document_id TEXT REFERENCES documents(id), - folder_id TEXT REFERENCES folders(id), - name TEXT NOT NULL, - mime_type TEXT NOT NULL, - data BYTEA NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -CREATE TABLE IF NOT EXISTS collaborators ( - id TEXT PRIMARY KEY, - document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(document_id, user_id) -); -CREATE TABLE IF NOT EXISTS invitations ( - id TEXT PRIMARY KEY, - document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - role TEXT NOT NULL, - token TEXT NOT NULL UNIQUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP -); -CREATE TABLE IF NOT EXISTS comments ( - id TEXT PRIMARY KEY, - document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - content TEXT NOT NULL, - resolved BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -CREATE TABLE IF NOT EXISTS document_history ( - id TEXT PRIMARY KEY, - document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - content BYTEA NOT NULL, - created_by TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -CREATE TABLE IF NOT EXISTS document_versions ( - id TEXT PRIMARY KEY, - document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - content TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - "#; - - for query in schema.split(';') { - let q = query.trim(); - if !q.is_empty() { - sqlx::query(q).execute(&pool).await.expect("Failed to execute schema query"); - } - } - - // Add public_role column if it doesn't exist - sqlx::query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT") - .execute(&pool) - .await - .unwrap_or_else(|e| { - eprintln!("Warning: Failed to add public_role column (might already exist): {}", e); - Default::default() - }); - - pool -} diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs new file mode 100644 index 0000000..fc160f7 --- /dev/null +++ b/server/src/db/mod.rs @@ -0,0 +1,36 @@ +mod postgres; +mod sqlite; + +use sqlx::any::AnyPoolOptions; +use sqlx::AnyPool; + +pub async fn init_db() -> AnyPool { + sqlx::any::install_default_drivers(); + + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:password@127.0.0.1:5432/typstdrive".to_string()); + + // DB_TYPE can override URL-based detection: "sqlite" or "postgres" + let db_type = std::env::var("DB_TYPE") + .unwrap_or_else(|_| { + if db_url.starts_with("sqlite") { + "sqlite".to_string() + } else { + "postgres".to_string() + } + }); + + let pool = AnyPoolOptions::new() + .max_connections(5) + .connect(&db_url) + .await + .expect("Failed to connect to database. Check DATABASE_URL."); + + match db_type.as_str() { + "sqlite" => sqlite::init_schema(&pool).await, + "postgres" => postgres::init_schema(&pool).await, + other => panic!("Unknown DB_TYPE '{}'. Expected 'sqlite' or 'postgres'.", other), + } + + pool +} diff --git a/server/src/db/postgres.rs b/server/src/db/postgres.rs new file mode 100644 index 0000000..a84da5d --- /dev/null +++ b/server/src/db/postgres.rs @@ -0,0 +1,94 @@ +use sqlx::AnyPool; + +pub async fn init_schema(pool: &AnyPool) { + let statements = [ + "CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT UNIQUE, + password_hash TEXT NOT NULL + )", + "CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + parent_id TEXT REFERENCES folders(id), + name TEXT NOT NULL, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS') + )", + "CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + folder_id TEXT REFERENCES folders(id), + title TEXT NOT NULL, + content BYTEA, + 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 files ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + document_id TEXT REFERENCES documents(id), + folder_id TEXT REFERENCES folders(id), + name TEXT NOT NULL, + mime_type TEXT NOT NULL, + data BYTEA NOT NULL, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS') + )", + "CREATE TABLE IF NOT EXISTS collaborators ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(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(document_id, user_id) + )", + "CREATE TABLE IF NOT EXISTS invitations ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + role TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'), + expires_at TEXT + )", + "CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + resolved BOOLEAN DEFAULT FALSE, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS') + )", + "CREATE TABLE IF NOT EXISTS document_history ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + content BYTEA NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS') + )", + "CREATE TABLE IF NOT EXISTS document_versions ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS') + )", + ]; + + for stmt in &statements { + sqlx::query(stmt) + .execute(pool) + .await + .expect("Failed to execute Postgres schema"); + } + + // Idempotent migration for existing databases with TIMESTAMP columns + sqlx::query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT") + .execute(pool) + .await + .unwrap_or_else(|e| { + eprintln!("Warning: public_role migration: {}", e); + Default::default() + }); +} diff --git a/server/src/db/sqlite.rs b/server/src/db/sqlite.rs new file mode 100644 index 0000000..b2279ab --- /dev/null +++ b/server/src/db/sqlite.rs @@ -0,0 +1,90 @@ +use sqlx::AnyPool; + +pub async fn init_schema(pool: &AnyPool) { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(pool) + .await + .expect("Failed to enable SQLite foreign keys"); + + let statements = [ + "CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT UNIQUE, + password_hash TEXT NOT NULL + )", + "CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + parent_id TEXT REFERENCES folders(id), + name TEXT NOT NULL, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')) + )", + "CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + folder_id TEXT REFERENCES folders(id), + title TEXT NOT NULL, + content BLOB, + 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 files ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + document_id TEXT REFERENCES documents(id), + folder_id TEXT REFERENCES folders(id), + name TEXT NOT NULL, + mime_type TEXT NOT NULL, + data BLOB NOT NULL, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')) + )", + "CREATE TABLE IF NOT EXISTS collaborators ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(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(document_id, user_id) + )", + "CREATE TABLE IF NOT EXISTS invitations ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + role TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), + expires_at TEXT + )", + "CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + resolved INTEGER DEFAULT 0, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')) + )", + "CREATE TABLE IF NOT EXISTS document_history ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + content BLOB NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')) + )", + "CREATE TABLE IF NOT EXISTS document_versions ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')) + )", + ]; + + for stmt in &statements { + sqlx::query(stmt) + .execute(pool) + .await + .expect("Failed to execute SQLite schema"); + } +} diff --git a/server/src/docs.rs b/server/src/docs.rs index 0559c28..cc6eae8 100644 --- a/server/src/docs.rs +++ b/server/src/docs.rs @@ -27,7 +27,7 @@ pub async fn list_documents( let docs = if let Some(folder_id) = query.folder_id { sqlx::query_as::<_, Document>( - "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE owner_id = $1 AND folder_id = $2 ORDER BY updated_at DESC" + "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC" ) .bind(&user_id) .bind(&folder_id) @@ -36,7 +36,7 @@ pub async fn list_documents( .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, public_role, created_at, updated_at FROM documents WHERE owner_id = $1 AND folder_id IS NULL ORDER BY updated_at DESC" + "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, 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) @@ -57,7 +57,7 @@ pub async fn create_document( .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"); @@ -70,7 +70,7 @@ pub async fn create_document( }; let doc = sqlx::query_as::<_, Document>( - "INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES ($1, $2, $3, $4, $5) RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at" + "INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES (?, ?, ?, ?, ?) RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at" ) .bind(&doc_id) .bind(&user_id) @@ -92,7 +92,7 @@ pub async fn get_document( let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let mut doc = sqlx::query_as::<_, Document>( - "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = $1" + "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) @@ -105,15 +105,13 @@ pub async fn get_document( if let Some(uid) = &user_id_opt { if &doc.owner_id == uid { effective_role = "owner".to_string(); - } else { - if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2") - .bind(&id) - .bind(uid) - .fetch_optional(&state.db) - .await - { - effective_role = role; - } + } else if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?") + .bind(&id) + .bind(uid) + .fetch_optional(&state.db) + .await + { + effective_role = role; } } @@ -142,9 +140,8 @@ pub async fn update_document( 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, public_role, created_at, updated_at FROM documents WHERE id = $1 AND owner_id = $2" + "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ? AND owner_id = ?" ) .bind(&id) .bind(&user_id) @@ -171,9 +168,8 @@ pub async fn update_document( } } - let doc = sqlx::query_as::<_, Document>( - "UPDATE documents SET title = $1, folder_id = $2, public_role = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 AND owner_id = $5 RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at" + "UPDATE documents SET title = ?, folder_id = ?, public_role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at" ) .bind(&doc.title) .bind(&doc.folder_id) @@ -195,7 +191,7 @@ pub async fn delete_document( 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 = $1 AND owner_id = $2") + let result = sqlx::query("DELETE FROM documents WHERE id = ? AND owner_id = ?") .bind(&id) .bind(&user_id) .execute(&state.db) @@ -218,8 +214,7 @@ pub async fn upload_file( 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)>("SELECT id, folder_id FROM documents WHERE id = $1 AND owner_id = $2") + let doc_exists = sqlx::query_as::<_, (String, Option)>("SELECT id, folder_id FROM documents WHERE id = ? AND owner_id = ?") .bind(&doc_id) .bind(&user_id) .fetch_optional(&state.db) @@ -248,7 +243,7 @@ pub async fn upload_file( } } - sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6, $7)") + 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) diff --git a/server/src/files.rs b/server/src/files.rs index a04bef5..3c0c3ae 100644 --- a/server/src/files.rs +++ b/server/src/files.rs @@ -28,7 +28,7 @@ pub async fn list_files( 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 = $1 AND folder_id = $2 ORDER BY name ASC" + "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) @@ -37,7 +37,7 @@ pub async fn list_files( .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 = $1 AND folder_id IS NULL ORDER BY name ASC" + "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) @@ -79,7 +79,7 @@ pub async fn upload_file_global( } } - sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6)") + 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) @@ -108,7 +108,7 @@ pub async fn get_file_data( 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)>("SELECT mime_type, data FROM files WHERE id = $1 AND owner_id = $2") + let file = sqlx::query_as::<_, (String, Vec)>("SELECT mime_type, data FROM files WHERE id = ? AND owner_id = ?") .bind(&id) .bind(&user_id) .fetch_optional(&state.db) @@ -133,7 +133,7 @@ pub async fn delete_file( 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 = $1 AND owner_id = $2") + let result = sqlx::query("DELETE FROM files WHERE id = ? AND owner_id = ?") .bind(&id) .bind(&user_id) .execute(&state.db) @@ -154,26 +154,24 @@ pub async fn list_fonts( 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 = sqlx::query_as::<_, (String,)>( - "SELECT name FROM files WHERE owner_id = $1" + let files = sqlx::query_as::<_, (String, Vec)>( + "SELECT name, data FROM files WHERE owner_id = ?" ) .bind(&user_id) .fetch_all(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let mut fonts = Vec::new(); - for (name,) in files { + let mut families = std::collections::BTreeSet::new(); + for (name, data) in files { if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") { - if let Some(stem) = std::path::Path::new(&name).file_stem() { - if let Some(stem_str) = stem.to_str() { - fonts.push(stem_str.to_string()); - } + for font in typst::text::Font::iter(typst::foundations::Bytes::new(data)) { + families.insert(font.info().family.clone()); } } } - Ok(Json(fonts)) + Ok(Json(families.into_iter().collect())) } #[derive(Deserialize)] @@ -192,7 +190,7 @@ pub async fn update_file( .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 = $1 AND owner_id = $2" + "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) @@ -213,7 +211,7 @@ pub async fn update_file( } let file = sqlx::query_as::<_, File>( - "UPDATE files SET name = $1, folder_id = $2 WHERE id = $3 AND owner_id = $4 RETURNING id, owner_id, document_id, folder_id, name, mime_type, created_at" + "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) diff --git a/server/src/folders.rs b/server/src/folders.rs index 98a6841..ec9cd10 100644 --- a/server/src/folders.rs +++ b/server/src/folders.rs @@ -27,7 +27,7 @@ pub async fn list_folders( 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 = $1 AND parent_id = $2 ORDER BY name ASC" + "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) @@ -36,7 +36,7 @@ pub async fn list_folders( .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 = $1 AND parent_id IS NULL ORDER BY name ASC" + "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) @@ -58,7 +58,7 @@ pub async fn create_folder( let folder_id = Uuid::new_v4().to_string(); let folder = sqlx::query_as::<_, Folder>( - "INSERT INTO folders (id, owner_id, parent_id, name) VALUES ($1, $2, $3, $4) RETURNING id, owner_id, parent_id, name, created_at" + "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) @@ -79,9 +79,7 @@ pub async fn delete_folder( 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 = $1 AND owner_id = $2") + let result = sqlx::query("DELETE FROM folders WHERE id = ? AND owner_id = ?") .bind(&id) .bind(&user_id) .execute(&state.db) @@ -110,7 +108,7 @@ pub async fn update_folder( .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; let folder = sqlx::query_as::<_, Folder>( - "UPDATE folders SET name = $1 WHERE id = $2 AND owner_id = $3 RETURNING id, owner_id, parent_id, name, created_at" + "UPDATE folders SET name = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, parent_id, name, created_at" ) .bind(&payload.name) .bind(&id) @@ -124,4 +122,3 @@ pub async fn update_folder( None => Err((StatusCode::NOT_FOUND, "Folder not found".to_string())), } } - diff --git a/server/src/handlers.rs b/server/src/handlers.rs index b6e8451..515dbdb 100644 --- a/server/src/handlers.rs +++ b/server/src/handlers.rs @@ -78,9 +78,9 @@ pub async fn yjs_handler( jar: axum_extra::extract::cookie::SignedCookieJar, ) -> 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 = $1" + "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) @@ -91,11 +91,11 @@ pub async fn yjs_handler( 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 = $1 AND user_id = $2 AND role = 'editor'") + } 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 + .await { is_viewer = false; } @@ -114,7 +114,7 @@ pub async fn yjs_handler( bcast.clone() } 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) { @@ -122,7 +122,7 @@ pub async fn yjs_handler( } } } - + 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()); @@ -136,7 +136,7 @@ pub async fn yjs_handler( interval.tick().await; let doc = save_awareness.read().await; let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default()); - let _ = sqlx::query("UPDATE documents SET content = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2") + let _ = sqlx::query("UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .bind(content) .bind(&save_id) .execute(&save_db) @@ -152,7 +152,7 @@ pub async fn yjs_handler( ws.on_upgrade(move |socket| async move { let (sink, stream) = socket.split(); let sink = Arc::new(Mutex::new(AxumSink(sink))); - + let filtered_stream = ViewerFilterStream { inner: stream, is_viewer, @@ -176,24 +176,28 @@ pub async fn compile_handler( let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); 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 = $1").bind(doc_id).fetch_one(&state.db).await { - - // Allow compilation if owner or if it has a public role or if they are a collaborator + 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 = ?" + ) + .bind(doc_id) + .fetch_one(&state.db) + .await + { let mut has_access = false; if let Some(uid) = &user_id_opt { if &doc.owner_id == uid { has_access = true; can_save_thumbnail = true; - } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2") + } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?") .bind(doc_id) .bind(uid) .fetch_optional(&state.db) - .await + .await { has_access = true; } } - + if !has_access { if let Some(pr) = &doc.public_role { if pr == "viewer" || pr == "editor" { @@ -203,7 +207,7 @@ pub async fn compile_handler( } if has_access { - if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = $1") + if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = ?") .bind(doc.owner_id) .fetch_all(&state.db) .await @@ -221,14 +225,14 @@ pub async fn compile_handler( Ok((svgs, thumbnail, stats)) => { if let Some(doc_id) = &payload.document_id { if can_save_thumbnail { - let _ = sqlx::query("UPDATE documents SET thumbnail_svg = $1 WHERE id = $2") + 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, @@ -264,17 +268,22 @@ pub async fn export_handler( let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); 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 = $1").bind(doc_id).fetch_one(&state.db).await { - + 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 = ?" + ) + .bind(doc_id) + .fetch_one(&state.db) + .await + { let mut has_access = false; if let Some(uid) = &user_id_opt { if &doc.owner_id == uid { has_access = true; - } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2") + } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?") .bind(doc_id) .bind(uid) .fetch_optional(&state.db) - .await + .await { has_access = true; } @@ -288,7 +297,7 @@ pub async fn export_handler( } if has_access { - if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = $1") + if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = ?") .bind(doc.owner_id) .fetch_all(&state.db) .await @@ -324,8 +333,6 @@ pub async fn export_handler( }, "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); @@ -425,7 +432,7 @@ pub async fn pandoc_import_handler( } else if file_name.ends_with(".html") { file_ext = "html".to_string(); } else { - file_ext = "markdown".to_string(); // fallback + file_ext = "markdown".to_string(); } } if let Ok(bytes) = field.bytes().await { @@ -482,7 +489,13 @@ pub async fn lsp_handler( ) -> impl IntoResponse { let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); - let doc = match 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 = $1").bind(&id).fetch_optional(&state.db).await { + let doc = match 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 = ?" + ) + .bind(&id) + .fetch_optional(&state.db) + .await + { Ok(Some(d)) => d, _ => return (StatusCode::NOT_FOUND, "Document not found").into_response(), }; @@ -491,11 +504,11 @@ pub async fn lsp_handler( if let Some(uid) = &user_id_opt { if &doc.owner_id == uid { has_access = true; - } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2") + } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ?") .bind(&id) .bind(uid) .fetch_optional(&state.db) - .await + .await { has_access = true; } @@ -513,7 +526,7 @@ pub async fn lsp_handler( } let mut files_map = std::collections::HashMap::new(); - if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = $1") + if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = ?") .bind(doc.owner_id) .fetch_all(&state.db) .await @@ -529,7 +542,7 @@ pub async fn lsp_handler( use std::process::Stdio; let temp_dir = tempfile::tempdir().unwrap(); - + for (name, data) in files_map { let path = temp_dir.path().join(&name); if let Some(parent) = path.parent() { diff --git a/server/src/main.rs b/server/src/main.rs index 00239ec..8b90130 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -3,7 +3,7 @@ use axum::{ Router, }; use axum_extra::extract::cookie::Key; -use sqlx::{Pool, Postgres}; +use sqlx::AnyPool; use std::sync::Arc; use std::collections::HashMap; use tokio::sync::Mutex; @@ -30,7 +30,7 @@ use handlers::{compile_handler, export_handler, yjs_handler}; pub struct AppState { pub compiler: Arc>, pub bcast_map: Arc>>>, - pub db: Pool, + pub db: AnyPool, pub key: Key, } diff --git a/server/src/models.rs b/server/src/models.rs index 6d8a1f4..1f2eebf 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -16,7 +16,7 @@ pub struct Folder { pub owner_id: String, pub parent_id: Option, pub name: String, - pub created_at: chrono::NaiveDateTime, + pub created_at: String, } #[derive(Debug, Serialize, Deserialize, FromRow)] @@ -27,7 +27,7 @@ pub struct File { pub folder_id: Option, pub name: String, pub mime_type: String, - pub created_at: chrono::NaiveDateTime, + pub created_at: String, } #[derive(Debug, Serialize, Deserialize, FromRow)] @@ -43,8 +43,8 @@ pub struct Document { #[serde(default)] #[sqlx(default)] pub effective_role: Option, - pub created_at: chrono::NaiveDateTime, - pub updated_at: chrono::NaiveDateTime, + pub created_at: String, + pub updated_at: String, } #[derive(Debug, Serialize, Deserialize)] @@ -105,7 +105,7 @@ pub struct Collaborator { pub document_id: String, pub user_id: String, pub role: String, - pub created_at: chrono::NaiveDateTime, + pub created_at: String, } #[derive(Debug, Serialize, Deserialize, FromRow)] @@ -114,8 +114,8 @@ pub struct Invitation { pub document_id: String, pub role: String, pub token: String, - pub created_at: chrono::NaiveDateTime, - pub expires_at: Option, + pub created_at: String, + pub expires_at: Option, } #[derive(Debug, Serialize, Deserialize, FromRow)] @@ -125,7 +125,7 @@ pub struct Comment { pub user_id: String, pub content: String, pub resolved: bool, - pub created_at: chrono::NaiveDateTime, + pub created_at: String, pub author_name: Option, } @@ -140,13 +140,13 @@ pub struct UpdateCommentRequest { pub resolved: Option, } -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Serialize, Deserialize, FromRow)] pub struct DocumentVersion { pub id: String, pub document_id: String, pub user_id: String, pub content: String, - pub created_at: chrono::NaiveDateTime, + pub created_at: String, #[sqlx(default)] pub author_name: Option, } diff --git a/server/src/world.rs b/server/src/world.rs index e6d6184..1f41f82 100644 --- a/server/src/world.rs +++ b/server/src/world.rs @@ -42,22 +42,13 @@ impl MemoryWorld { } } - // Add custom fonts from files + // Add custom fonts from files, registered only by their embedded metadata + // so that all variants (Bold, Italic, etc.) resolve correctly under one family name. for (name, data) in &files { if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") { for font in Font::iter(Bytes::new(data.clone())) { - let info = font.info().clone(); - book.push(info.clone()); - fonts.push(font.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(); - book.push(custom_info); - fonts.push(font); - } - } + book.push(font.info().clone()); + fonts.push(font); } } } diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte index f32295b..a30f0fe 100644 --- a/src/lib/components/Toolbar.svelte +++ b/src/lib/components/Toolbar.svelte @@ -205,9 +205,9 @@ if (data.filename) { if (data.filename.toLowerCase().endsWith('.ttf') || data.filename.toLowerCase().endsWith('.otf')) { triggerLspReconnect.update(n => n + 1); - let stem = data.filename.substring(0, data.filename.lastIndexOf('.')); - if (!uploadedFonts.includes(stem)) { - uploadedFonts = [...uploadedFonts, stem]; + const fontName = data.font_family || data.filename.substring(0, data.filename.lastIndexOf('.')); + if (!uploadedFonts.includes(fontName)) { + uploadedFonts = [...uploadedFonts, fontName]; } } const view = $editorViewStore; diff --git a/typst b/typst index d6848a8..de6f400 160000 --- a/typst +++ b/typst @@ -1 +1 @@ -Subproject commit d6848a802e86a6269300f9768c054a641c2da77f +Subproject commit de6f400976f9bf6ab8b923d13a068722959d0070